Skip to content

Commit 4554df9

Browse files
feat(file): add Manage Sharing operation to the File block (#5177)
* feat(file): add Set File Sharing operation to the File block Adds a new file_set_sharing operation to the File block (file_v5) that idempotently enables/disables a file's public share link and sets its access mode (public, password, email, SSO). The set_sharing route case reuses upsertFileShare, requires write/admin, gates enabling through the EE public-sharing policy, and records a share audit. Returns an empty url when set to private so a disabled link isn't handed back as a dead link. * fix(file): harden set_sharing — explicit isActive, agent-controllable params, policy gate + perm-check ordering Addresses review findings: - Make isActive explicit/required so a bare call no longer silently enables a public link - Expose isActive/authType/allowedEmails as user-or-llm so agents can disable/configure shares (password stays user-only) - Resolve authType from the existing share before the EE policy gate to close a re-enable bypass - Run the write/admin permission check before the file lookup to remove a file-existence side channel * refactor(file): rename file operation to Manage Sharing Renames the file_set_sharing operation to file_manage_sharing (route literal manage_sharing, tool Manage Sharing) across the contract, route, tool, block, registry, and tests. * fix(file): complete Manage Sharing rename in tools barrel Prior commit's lint-staged dropped the barrel re-export update, leaving index.ts importing the deleted set-sharing module and breaking the block registry check. Point the barrel at manage-sharing. * fix(file): require isActive in tool params type, reject multiple files in manage sharing - FileManageSharingParams.isActive is now required, matching the tool param and contract (no compile-time gap that 400s at runtime) - manage_sharing rejects multiple canonical file IDs instead of silently sharing only the first, matching decompress * fix(file): resolve basic-picker files for manage sharing The basic file-upload picker stores a workspace file as { name, path, key, size, type } with no canonical id, so manage_sharing failed those picks with 'Could not determine the file to share'. The block now passes the picked object as fileInput when it lacks an id, and the route resolves the canonical id from the storage key via getFileMetadataByKey. Contract accepts fileId OR fileInput (mirroring read/get content).
1 parent 7686706 commit 4554df9

8 files changed

Lines changed: 504 additions & 3 deletions

File tree

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Buffer, isUtf8 } from 'buffer'
22
import type { Readable } from 'stream'
3+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
34
import { createLogger } from '@sim/logger'
45
import { getErrorMessage } from '@sim/utils/errors'
56
import { generateShortId } from '@sim/utils/id'
@@ -14,6 +15,11 @@ import { generateRequestId } from '@/lib/core/utils/request'
1415
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
18+
import {
19+
getShareForResource,
20+
ShareValidationError,
21+
upsertFileShare,
22+
} from '@/lib/public-shares/share-manager'
1723
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
1824
import {
1925
fetchWorkspaceFileBuffer,
@@ -22,14 +28,20 @@ import {
2228
updateWorkspaceFileContent,
2329
uploadWorkspaceFile,
2430
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
31+
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
2532
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
2633
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
2734
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
2835
import {
2936
assertActiveWorkspaceAccess,
37+
getUserEntityPermissions,
3038
isWorkspaceAccessDeniedError,
3139
} from '@/lib/workspaces/permissions/utils'
3240
import { assertToolFileAccess } from '@/app/api/files/authorization'
41+
import {
42+
PublicFileSharingNotAllowedError,
43+
validatePublicFileSharing,
44+
} from '@/ee/access-control/utils/permission-check'
3345
import type { UserFile } from '@/executor/types'
3446

3547
export const dynamic = 'force-dynamic'
@@ -565,6 +577,102 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
565577
})
566578
}
567579

580+
case 'manage_sharing': {
581+
const { fileId, fileInput, isActive, authType, password, allowedEmails } = body
582+
583+
// Check permission before probing file existence so a read-only caller
584+
// can't distinguish 404 from 403 as a file-existence side channel.
585+
// Publishing is more sensitive than the other mutating ops, so it
586+
// requires write/admin (not just workspace access) like the share route.
587+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
588+
if (permission !== 'admin' && permission !== 'write') {
589+
return NextResponse.json(
590+
{ success: false, error: 'Insufficient permissions' },
591+
{ status: 403 }
592+
)
593+
}
594+
595+
// Resolve the canonical file id. The basic file picker provides an object
596+
// with a storage `key` but no id, so map the key to the workspace file row.
597+
let resolvedFileId = typeof fileId === 'string' ? fileId : undefined
598+
if (!resolvedFileId && fileInput) {
599+
const single = Array.isArray(fileInput) ? fileInput[0] : fileInput
600+
if (single && typeof single === 'object') {
601+
const record = single as Record<string, unknown>
602+
if (typeof record.id === 'string' && record.id) resolvedFileId = record.id
603+
else if (typeof record.fileId === 'string' && record.fileId)
604+
resolvedFileId = record.fileId
605+
else if (typeof record.key === 'string' && record.key) {
606+
const meta = await getFileMetadataByKey(record.key, 'workspace')
607+
resolvedFileId = meta?.id
608+
}
609+
}
610+
}
611+
if (!resolvedFileId) {
612+
return NextResponse.json(
613+
{ success: false, error: 'A valid file is required to manage sharing' },
614+
{ status: 400 }
615+
)
616+
}
617+
618+
const file = await getWorkspaceFile(workspaceId, resolvedFileId)
619+
if (!file) {
620+
return NextResponse.json(
621+
{ success: false, error: `File not found: "${resolvedFileId}"` },
622+
{ status: 404 }
623+
)
624+
}
625+
626+
// Enabling a share is gated by the org's access-control policy; disabling
627+
// is always allowed so users can un-share after the policy is turned on.
628+
if (isActive) {
629+
// Resolve the auth type the same way upsertFileShare will (falling back
630+
// to the existing share's type) so the policy gate can't be bypassed by
631+
// re-enabling a pre-existing restricted share without an explicit authType.
632+
const existingShare = await getShareForResource('file', resolvedFileId)
633+
const resolvedAuthType = authType ?? existingShare?.authType ?? 'public'
634+
try {
635+
await validatePublicFileSharing(userId, workspaceId, resolvedAuthType)
636+
} catch (error) {
637+
if (error instanceof PublicFileSharingNotAllowedError) {
638+
return NextResponse.json({ success: false, error: error.message }, { status: 403 })
639+
}
640+
throw error
641+
}
642+
}
643+
644+
const share = await upsertFileShare({
645+
workspaceId,
646+
fileId: resolvedFileId,
647+
userId,
648+
isActive,
649+
authType,
650+
password,
651+
allowedEmails,
652+
})
653+
654+
recordAudit({
655+
workspaceId,
656+
actorId: userId,
657+
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
658+
resourceType: AuditResourceType.FILE,
659+
resourceId: resolvedFileId,
660+
resourceName: file.name,
661+
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`,
662+
request,
663+
})
664+
665+
logger.info('File sharing updated', {
666+
fileId: resolvedFileId,
667+
isActive,
668+
authType: share.authType,
669+
})
670+
671+
// A disabled link doesn't resolve, so don't hand back a dead URL.
672+
const responseShare = share.isActive ? share : { ...share, url: '' }
673+
return NextResponse.json({ success: true, data: { share: responseShare } })
674+
}
675+
568676
case 'append': {
569677
const { fileName, content } = body
570678

@@ -911,6 +1019,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9111019
{ status: 403 }
9121020
)
9131021
}
1022+
if (error instanceof ShareValidationError) {
1023+
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
1024+
}
9141025
const message = getErrorMessage(error, 'Unknown error')
9151026
logger.error('File operation failed', { operation: body.operation, error: message })
9161027
return NextResponse.json({ success: false, error: message }, { status: 500 })

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ describe.concurrent('Blocks Module', () => {
174174
'file_append',
175175
'file_compress',
176176
'file_decompress',
177+
'file_manage_sharing',
177178
])
178179
expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress')
179180
expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress')

apps/sim/blocks/blocks/file.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,125 @@ describe('FileV5Block', () => {
117117
'File is required for get content'
118118
)
119119
})
120+
121+
it('maps manage sharing to public access for a canonical file ID', () => {
122+
expect(
123+
buildParams({
124+
operation: 'file_manage_sharing',
125+
shareInput: 'file-1',
126+
shareVisibility: 'public',
127+
_context: { workspaceId: 'workspace-1' },
128+
})
129+
).toEqual({
130+
fileId: 'file-1',
131+
isActive: true,
132+
authType: 'public',
133+
password: undefined,
134+
allowedEmails: undefined,
135+
workspaceId: 'workspace-1',
136+
})
137+
})
138+
139+
it('maps private visibility to a disabled share with no authType', () => {
140+
expect(
141+
buildParams({
142+
operation: 'file_manage_sharing',
143+
shareInput: 'file-1',
144+
shareVisibility: 'private',
145+
_context: { workspaceId: 'workspace-1' },
146+
})
147+
).toMatchObject({
148+
fileId: 'file-1',
149+
isActive: false,
150+
authType: undefined,
151+
})
152+
})
153+
154+
it('passes the password through for password visibility', () => {
155+
expect(
156+
buildParams({
157+
operation: 'file_manage_sharing',
158+
shareInput: 'file-1',
159+
shareVisibility: 'password',
160+
sharePassword: 'hunter2',
161+
_context: { workspaceId: 'workspace-1' },
162+
})
163+
).toMatchObject({
164+
fileId: 'file-1',
165+
isActive: true,
166+
authType: 'password',
167+
password: 'hunter2',
168+
})
169+
})
170+
171+
it('splits allowed emails for email visibility', () => {
172+
expect(
173+
buildParams({
174+
operation: 'file_manage_sharing',
175+
shareInput: 'file-1',
176+
shareVisibility: 'email',
177+
shareAllowedEmails: 'a@example.com, b@example.com\n@acme.com',
178+
_context: { workspaceId: 'workspace-1' },
179+
})
180+
).toMatchObject({
181+
fileId: 'file-1',
182+
isActive: true,
183+
authType: 'email',
184+
allowedEmails: ['a@example.com', 'b@example.com', '@acme.com'],
185+
})
186+
})
187+
188+
it('resolves the file ID from a selected workspace file object for manage sharing', () => {
189+
expect(
190+
buildParams({
191+
operation: 'file_manage_sharing',
192+
shareInput: [{ id: 'file-9', name: 'report.pdf' }],
193+
shareVisibility: 'public',
194+
_context: { workspaceId: 'workspace-1' },
195+
})
196+
).toMatchObject({
197+
fileId: 'file-9',
198+
isActive: true,
199+
authType: 'public',
200+
})
201+
})
202+
203+
it('passes a picker file object without an id through as fileInput for manage sharing', () => {
204+
const picked = {
205+
name: 'report.pdf',
206+
key: 'workspace/workspace-1/123-abc-report.pdf',
207+
path: '/api/files/serve/workspace%2Fworkspace-1%2F123-abc-report.pdf?context=workspace',
208+
size: 10,
209+
type: 'application/pdf',
210+
}
211+
expect(
212+
buildParams({
213+
operation: 'file_manage_sharing',
214+
shareInput: [picked],
215+
shareVisibility: 'public',
216+
_context: { workspaceId: 'workspace-1' },
217+
})
218+
).toMatchObject({
219+
fileInput: picked,
220+
isActive: true,
221+
authType: 'public',
222+
})
223+
})
224+
225+
it('throws when no file is provided for manage sharing', () => {
226+
expect(() => buildParams({ operation: 'file_manage_sharing' })).toThrow(
227+
'File is required to manage sharing'
228+
)
229+
})
230+
231+
it('rejects multiple file IDs for manage sharing', () => {
232+
expect(() =>
233+
buildParams({
234+
operation: 'file_manage_sharing',
235+
shareInput: '["file-1","file-2"]',
236+
shareVisibility: 'public',
237+
_context: { workspaceId: 'workspace-1' },
238+
})
239+
).toThrow('Manage Sharing accepts a single file at a time')
240+
})
120241
})

0 commit comments

Comments
 (0)