Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/sim/app/api/files/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,69 @@ describe('public-context access (profile-pictures / og-images / workspace-logos)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
})

/**
* Chat-scoped `output` files belong to a private chat: workspace membership
* alone must NOT grant access — the caller must also be the row's owner.
* These lock in the fix for the member-reads-another-member's-outputs leak,
* on both the explicit-context path (view route) and the inferred-workspace
* path (serve route — output keys share the workspace key shape).
*/
describe('chat output file ownership (verifyFileAccess)', () => {
const OUTPUT_KEY = 'workspace/ws-1/1780000000-abcd-generated-image.png'
const outputRow = {
workspaceId: 'ws-1',
userId: 'owner-user',
context: 'output',
deletedAt: null,
}

beforeEach(() => {
vi.clearAllMocks()
})

it('denies a workspace member who is not the owning chat user (explicit output context)', async () => {
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
mockGetUserEntityPermissions.mockResolvedValue('read')

await expect(verifyFileAccess(OUTPUT_KEY, 'other-member', undefined, 'output')).resolves.toBe(
false
)
})

it('denies a non-owner member when context is inferred as workspace (serve path)', async () => {
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
mockGetUserEntityPermissions.mockResolvedValue('read')

await expect(
verifyFileAccess(OUTPUT_KEY, 'other-member', undefined, 'workspace')
).resolves.toBe(false)
})

it('grants the owning chat user with workspace membership', async () => {
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
mockGetUserEntityPermissions.mockResolvedValue('read')

await expect(verifyFileAccess(OUTPUT_KEY, 'owner-user', undefined, 'output')).resolves.toBe(
true
)
})

it('still denies the owner without workspace membership (left the workspace)', async () => {
mockGetFileMetadataByKey.mockResolvedValue(outputRow)
mockGetUserEntityPermissions.mockResolvedValue(null)

await expect(verifyFileAccess(OUTPUT_KEY, 'owner-user', undefined, 'output')).resolves.toBe(
false
)
})

it('leaves plain workspace files on membership-only auth', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ ...outputRow, context: 'workspace' })
mockGetUserEntityPermissions.mockResolvedValue('read')

await expect(
verifyFileAccess(OUTPUT_KEY, 'other-member', undefined, 'workspace')
).resolves.toBe(true)
})
})
76 changes: 71 additions & 5 deletions apps/sim/app/api/files/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ import { isUuid } from '@/executor/constants'

const logger = createLogger('FileAuthorization')

/**
* Contexts this key lookup resolves for workspace-membership authorization:
* `workspace` (durable UI files) and `output` (chat-scoped agent outputs). Both
* share the `workspace/<id>/...` key shape and are authorized by workspace
* membership, so a by-key match on either is safe to grant to any member.
*
* `mothership` chat uploads ALSO share the bucket/key shape but are deliberately
* left OUT: today they authorize via the Priority-2 object-metadata fallback, and
* moving them onto this Priority-1 DB path would change how uploads work — an
* unrelated change deferred to its own PR (see outputs-vfs-followups.md #2).
* Owner-scoped contexts that also live in `workspace_files` (`copilot`,
* `knowledge-base`, `workspace-logos`) are excluded too: they have stricter
* per-owner rules and must never be granted through workspace membership on a
* key collision.
*/
const WORKSPACE_FILE_LOOKUP_CONTEXTS: StorageContext[] = ['workspace', 'output']

/** Thrown by utility functions when file access is denied, so route handlers can return 404. */
export class FileAccessDeniedError extends Error {
constructor() {
Expand Down Expand Up @@ -43,6 +60,21 @@ function workspacePermissionSatisfies(
return permissionSatisfies(permission, requireWrite ? 'write' : 'read')
}

/**
* Chat-scoped `output` rows belong to a PRIVATE chat: workspace membership
* alone is not enough — the caller must also be the row's owner (stamped from
* the chat owner at creation and re-stamped on fork/duplicate). Without this,
* any workspace member who learns a file id or storage key could read another
* member's agent outputs, even though listing outputs requires chat ownership.
* Non-output contexts pass through unchanged.
*/
function outputOwnershipSatisfied(
record: { context: string; uploadedBy: string },
userId: string
): boolean {
return record.context !== 'output' || record.uploadedBy === userId
}

/**
* Lookup workspace file by storage key from database
* @param key Storage key to lookup
Expand All @@ -51,16 +83,26 @@ function workspacePermissionSatisfies(
async function lookupWorkspaceFileByKey(
key: string,
options?: { includeDeleted?: boolean }
): Promise<{ workspaceId: string; uploadedBy: string } | null> {
): Promise<{ workspaceId: string; uploadedBy: string; context: string } | null> {
try {
const { includeDeleted = false } = options ?? {}
// Priority 1: Check new workspaceFiles table
const fileRecord = await getFileMetadataByKey(key, 'workspace', { includeDeleted })
// Priority 1: Check new workspaceFiles table. Look up by key across
// WORKSPACE_FILE_LOOKUP_CONTEXTS (`workspace` + `output`): both share the
// `workspace/<id>/...` key shape. `workspace` rows authorize by workspace
// membership; `output` rows additionally require ownership (see
// outputOwnershipSatisfied). Filtering to `workspace` alone made `output`
// files unservable (broken previews); scoping to this explicit set (rather
// than dropping the filter) keeps outputs servable while leaving upload
// (`mothership`) authorization and the owner-scoped contexts untouched.
const fileRecord = await getFileMetadataByKey(key, WORKSPACE_FILE_LOOKUP_CONTEXTS, {
includeDeleted,
})
Comment thread
cursor[bot] marked this conversation as resolved.

if (fileRecord) {
return {
workspaceId: fileRecord.workspaceId || '',
uploadedBy: fileRecord.userId,
context: fileRecord.context,
}
}

Expand All @@ -83,6 +125,7 @@ async function lookupWorkspaceFileByKey(
return {
workspaceId: legacyFile.workspaceId,
uploadedBy: legacyFile.uploadedBy,
context: 'workspace',
}
}
} catch (legacyError) {
Expand Down Expand Up @@ -157,8 +200,15 @@ export async function verifyFileAccess(
return true
}

// 1. Workspace / mothership files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace' || inferredContext === 'mothership') {
// 1. Workspace / mothership / chat-output files: check database first (most
// reliable for both local and cloud). `output` shares the workspace key
// shape; explicitly routing it here (instead of letting it fall through to
// verifyRegularFileAccess) keeps its ownership rule applied on every path.
if (
inferredContext === 'workspace' ||
inferredContext === 'mothership' ||
inferredContext === 'output'
) {
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite)
}

Expand Down Expand Up @@ -218,6 +268,14 @@ async function verifyWorkspaceFileAccess(
// Priority 1: Check database (most reliable, works for both local and cloud)
const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey)
if (workspaceFileRecord) {
if (!outputOwnershipSatisfied(workspaceFileRecord, userId)) {
logger.warn('Chat output file access denied: caller is not the owning chat user', {
userId,
workspaceId: workspaceFileRecord.workspaceId,
cloudKey,
})
return false
}
const permission = await getUserEntityPermissions(
userId,
'workspace',
Expand Down Expand Up @@ -647,6 +705,14 @@ async function verifyRegularFileAccess(
// This handles legacy files that might not have metadata
const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey)
if (workspaceFileRecord) {
if (!outputOwnershipSatisfied(workspaceFileRecord, userId)) {
logger.warn('Chat output file access denied: caller is not the owning chat user', {
userId,
workspaceId: workspaceFileRecord.workspaceId,
cloudKey,
})
return false
}
const permission = await getUserEntityPermissions(
userId,
'workspace',
Expand Down
14 changes: 8 additions & 6 deletions apps/sim/app/api/files/view/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,16 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}

// Only workspace-scoped files are embeddable/viewable here. Other contexts (e.g. chat-scoped
// `mothership` uploads) are not durable workspace artifacts; now that the caller is known to have
// access, reject with a legible 422 so the embed fails cleanly and the file agent can self-correct.
if (record.context !== 'workspace') {
logger.warn('Rejected non-workspace file view', { id, context: record.context })
// Embeddable contexts: durable `workspace` files and chat-scoped `output` files
// (agent-generated one-offs the user previews inline before optionally saving to
// the workspace). Other contexts (e.g. `mothership` user uploads) are not embeddable
// here; now that the caller is known to have access, reject with a legible 422 so the
// embed fails cleanly and the file agent can self-correct.
if (record.context !== 'workspace' && record.context !== 'output') {
logger.warn('Rejected non-embeddable file view', { id, context: record.context })
return NextResponse.json(
{
error: `File ${id} has context "${record.context}" and is not embeddable. Only workspace files can be viewed via /api/files/view. Save it into the workspace and reference the workspace copy.`,
error: `File ${id} has context "${record.context}" and is not embeddable. Only workspace and output files can be viewed via /api/files/view. Save it into the workspace and reference the workspace copy.`,
},
{ status: 422 }
)
Expand Down
Loading
Loading