From 4af6de7d46742f7ce12a425298463f21fc15d4fa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 12:29:04 -0800 Subject: [PATCH 1/2] fix(realtime): enforce the Files capability on workspace document rooms --- apps/realtime/src/handlers/file-doc-app.ts | 37 ++++ .../file-doc-capabilities.integration.ts | 88 ++++++-- .../handlers/file-doc.multireplica.test.ts | 1 + .../src/handlers/file-doc.redis-store.test.ts | 17 +- apps/realtime/src/handlers/file-doc.test.ts | 58 +++--- apps/realtime/src/handlers/room-join-auth.ts | 24 +-- apps/realtime/src/middleware/permissions.ts | 46 ++++- .../app/api/internal/file-doc/access/route.ts | 34 +++ apps/sim/lib/api/contracts/file-doc.ts | 30 +++ apps/sim/lib/collab-doc/access.integration.ts | 195 ++++++++++++++++++ apps/sim/lib/collab-doc/access.ts | 37 ++++ packages/realtime-protocol/src/rooms.ts | 20 +- 12 files changed, 512 insertions(+), 75 deletions(-) create mode 100644 apps/sim/app/api/internal/file-doc/access/route.ts create mode 100644 apps/sim/lib/collab-doc/access.integration.ts create mode 100644 apps/sim/lib/collab-doc/access.ts diff --git a/apps/realtime/src/handlers/file-doc-app.ts b/apps/realtime/src/handlers/file-doc-app.ts index 546c032acfb..5c4508f7094 100644 --- a/apps/realtime/src/handlers/file-doc-app.ts +++ b/apps/realtime/src/handlers/file-doc-app.ts @@ -1,5 +1,7 @@ +import { isPermissionType } from '@sim/platform-authz/predicates' import type { RoomAuthorizationResult } from '@sim/platform-authz/rooms' import { FILE_DOC_INTERNAL_HEADERS, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { toRecord } from '@sim/utils/object' import { env, getBaseUrl } from '@/env' /** @@ -32,6 +34,41 @@ function postToApp( }) } +/** + * Resolve a user's access to a workspace file's document room through the app. Only a decision in + * the body denies; any other answer throws, so an outage is never read as a revocation. + */ +export async function fetchFileDocAccess( + fileId: string, + userId: string +): Promise { + const response = await postToApp( + '/api/internal/file-doc/access', + { fileId, userId }, + FILE_DOC_TIMEOUTS.seedRequestMs + ) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Document authorization failed: ${response.status}`) + } + const body = toRecord(await response.json()) + if (body.allowed === false && (body.status === 403 || body.status === 404)) + return { allowed: false, status: body.status, workspaceId: null, workspacePermission: null } + if ( + body.allowed !== true || + typeof body.workspaceId !== 'string' || + !body.workspaceId || + !isPermissionType(body.workspacePermission) + ) + throw new Error('Malformed document authorization') + return { + allowed: true, + status: 200, + workspaceId: body.workspaceId, + workspacePermission: body.workspacePermission, + } +} + /** * Existing empty files have named, versioned seeds; only missing files return null. * Transport and malformed-response failures throw so callers retry instead of creating empty rooms. diff --git a/apps/realtime/src/handlers/file-doc-capabilities.integration.ts b/apps/realtime/src/handlers/file-doc-capabilities.integration.ts index bd5b5a0b56b..fee4db2ccb0 100644 --- a/apps/realtime/src/handlers/file-doc-capabilities.integration.ts +++ b/apps/realtime/src/handlers/file-doc-capabilities.integration.ts @@ -13,7 +13,7 @@ import { type JoinFileDocSuccess, } from '@sim/realtime-protocol/file-doc' import { type FileDocOwner, fileDocRoom } from '@sim/realtime-protocol/file-doc-target' -import { roomName } from '@sim/realtime-protocol/rooms' +import { ROOM_TYPES, roomName } from '@sim/realtime-protocol/rooms' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' @@ -56,7 +56,9 @@ let http: HttpServer let io: Server let manager: MemoryRoomManager let socketUrl: string -let failProjectAccess = false +let failAccessStatus: number | null = null +/** Users whose permission group withholds the Files module, as the app's access route decides. */ +const filesWithheld = new Set() async function listen(server: HttpServer) { await new Promise((done) => server.listen(0, '127.0.0.1', done)) @@ -113,8 +115,28 @@ beforeAll(async () => { const fileId = project ? path.split('/')[5] : body.fileId const actor = project ? request.headers['x-sim-subject-user-id'] : body.userId if (path.endsWith('/access')) { - if (failProjectAccess) { - response.writeHead(503).end('{}') + if (failAccessStatus !== null) { + response.writeHead(failAccessStatus).end('{}') + return + } + if (!project) { + const authorization = await roomAuthorization.authorizeRoom({ + userId: actor, + room: { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId }, + }) + if (!authorization.allowed || filesWithheld.has(actor)) { + response.end( + JSON.stringify({ allowed: false, status: authorization.status === 404 ? 404 : 403 }) + ) + return + } + response.end( + JSON.stringify({ + allowed: true, + workspaceId: authorization.workspaceId, + workspacePermission: authorization.workspacePermission, + }) + ) return } response.end( @@ -380,13 +402,7 @@ for (const scope of ['workspace', 'project'] as const) const writer = await join(target, writerId) const now = Date.now() const clock = vi.spyOn(Date, 'now').mockReturnValue(now + ROLE_REVALIDATION_TTL_MS + 1) - const authorization = - scope === 'workspace' - ? vi - .spyOn(roomAuthorization, 'authorizeRoom') - .mockRejectedValue(new Error('Fixture authorization outage')) - : null - failProjectAccess = true + failAccessStatus = 503 try { expect( await writer.socket.timeout(3000).emitWithAck(FILE_DOC_EVENTS.UPDATE, { @@ -400,14 +416,60 @@ for (const scope of ['workspace', 'project'] as const) io.sockets.sockets.get(writer.socket.id ?? '')?.rooms.has(roomName(fileDocRoom(target))) ).toBe(true) } finally { - authorization?.mockRestore() clock.mockRestore() - failProjectAccess = false + failAccessStatus = null } } ) }) +describe('workspace document rooms answer to the Files capability', () => { + check('a member whose permission group withholds Files is refused the room', async () => { + const target = await fixture('workspace') + filesWithheld.add(writerId) + try { + await expect(join(target, writerId)).rejects.toThrow('ACCESS_DENIED') + } finally { + filesWithheld.delete(writerId) + } + }) + + check('an app that predates the access route holds the room instead of revoking it', async () => { + const target = await fixture('workspace') + const writer = await join(target, writerId) + const room = fileDocRoom(target) + failAccessStatus = 404 + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + ROLE_REVALIDATION_TTL_MS + 1) + const sweep = startAccessRevalidationSweep(manager) + try { + await expect(join(target, readerId)).rejects.toThrow('VERIFY_ACCESS_FAILED') + await sweep.runOnce() + expect(io.sockets.sockets.get(writer.socket.id ?? '')?.rooms.has(roomName(room))).toBe(true) + } finally { + sweep.stop() + clock.mockRestore() + failAccessStatus = null + } + }) + + check('withdrawing Files mid-session evicts the member on the next revalidation', async () => { + const target = await fixture('workspace') + const writer = await join(target, writerId) + const room = fileDocRoom(target) + filesWithheld.add(writerId) + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + ROLE_REVALIDATION_TTL_MS + 1) + const sweep = startAccessRevalidationSweep(manager) + try { + await sweep.runOnce() + expect(io.sockets.sockets.get(writer.socket.id ?? '')?.rooms.has(roomName(room))).toBe(false) + } finally { + sweep.stop() + clock.mockRestore() + filesWithheld.delete(writerId) + } + }) +}) + afterAll(async () => { for (const socket of clients) socket.disconnect() if (io) await new Promise((done) => io.close(() => done())) diff --git a/apps/realtime/src/handlers/file-doc.multireplica.test.ts b/apps/realtime/src/handlers/file-doc.multireplica.test.ts index d78cbb84c19..15dacefc678 100644 --- a/apps/realtime/src/handlers/file-doc.multireplica.test.ts +++ b/apps/realtime/src/handlers/file-doc.multireplica.test.ts @@ -37,6 +37,7 @@ const fakeStore = { vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: vi.fn() })) vi.mock('@/handlers/file-doc-app', () => ({ + fetchFileDocAccess: vi.fn(), fetchFileDocSeed: vi.fn(), fetchFileDocMerge: mockFetchFileDocMerge, fetchFileDocPersist: vi.fn(), diff --git a/apps/realtime/src/handlers/file-doc.redis-store.test.ts b/apps/realtime/src/handlers/file-doc.redis-store.test.ts index 6f5d451f3c8..83c53f750de 100644 --- a/apps/realtime/src/handlers/file-doc.redis-store.test.ts +++ b/apps/realtime/src/handlers/file-doc.redis-store.test.ts @@ -20,15 +20,18 @@ import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import type { IRoomManager } from '@/rooms' -const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocPersist } = vi.hoisted(() => ({ - mockAuthorizeRoom: vi.fn(), - mockFetchFileDocSeed: vi.fn(), - mockFetchFileDocPersist: vi.fn(), -})) +const { mockFetchFileDocAccess, mockFetchFileDocSeed, mockFetchFileDocPersist } = vi.hoisted( + () => ({ + mockFetchFileDocAccess: vi.fn(), + mockFetchFileDocSeed: vi.fn(), + mockFetchFileDocPersist: vi.fn(), + }) +) -vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom })) +vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: vi.fn() })) vi.mock('@/handlers/file-doc-app', () => ({ + fetchFileDocAccess: mockFetchFileDocAccess, fetchFileDocSeed: mockFetchFileDocSeed, fetchFileDocMerge: vi.fn(), fetchFileDocPersist: mockFetchFileDocPersist, @@ -263,7 +266,7 @@ describe('file-doc join readiness (shared store enabled)', () => { backing.kv.clear() backing.seq = 0 backing.readDelayTicks = 0 - mockAuthorizeRoom.mockResolvedValue({ + mockFetchFileDocAccess.mockResolvedValue({ allowed: true, status: 200, workspaceId: 'ws-1', diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 621d3361088..03355f85eef 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -16,19 +16,22 @@ import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import type { IRoomManager } from '@/rooms' -const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocMerge, mockFetchFileDocPersist } = - vi.hoisted(() => ({ - mockAuthorizeRoom: vi.fn(), - mockFetchFileDocSeed: vi.fn(), - mockFetchFileDocMerge: vi.fn(), - mockFetchFileDocPersist: vi.fn(), - })) - -vi.mock('@sim/platform-authz/rooms', () => ({ - authorizeRoom: mockAuthorizeRoom, +const { + mockFetchFileDocAccess, + mockFetchFileDocSeed, + mockFetchFileDocMerge, + mockFetchFileDocPersist, +} = vi.hoisted(() => ({ + mockFetchFileDocAccess: vi.fn(), + mockFetchFileDocSeed: vi.fn(), + mockFetchFileDocMerge: vi.fn(), + mockFetchFileDocPersist: vi.fn(), })) +vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: vi.fn() })) + vi.mock('@/handlers/file-doc-app', () => ({ + fetchFileDocAccess: mockFetchFileDocAccess, fetchFileDocSeed: mockFetchFileDocSeed, fetchFileDocMerge: mockFetchFileDocMerge, fetchFileDocPersist: mockFetchFileDocPersist, @@ -213,7 +216,7 @@ function joinSuccessFileId(socket: { emit: ReturnType }) { describe('setupWorkspaceFileDocHandlers', () => { beforeEach(() => { - mockAuthorizeRoom.mockResolvedValue({ + mockFetchFileDocAccess.mockResolvedValue({ allowed: true, status: 200, workspaceId: 'ws-1', @@ -251,7 +254,7 @@ describe('setupWorkspaceFileDocHandlers', () => { }) it('fails closed when authorization does not resolve a workspace context', async () => { - mockAuthorizeRoom.mockResolvedValueOnce({ + mockFetchFileDocAccess.mockResolvedValueOnce({ allowed: true, status: 200, workspacePermission: 'write', @@ -445,7 +448,7 @@ describe('setupWorkspaceFileDocHandlers', () => { const { io } = createIo() const { socket, handlers } = setup('socket-race', io, { userId: 'user-race' }) - mockAuthorizeRoom.mockImplementation(async () => { + mockFetchFileDocAccess.mockImplementation(async () => { // Simulate the revocation landing between this join's authorize and its commit, // exactly as the sweep would record it. commitRoomPermission( @@ -479,16 +482,21 @@ describe('setupWorkspaceFileDocHandlers', () => { const { io } = createIo() const { socket, handlers } = setup('socket-stale', io, { userId: 'user-stale' }) - mockAuthorizeRoom.mockImplementation(async ({ action }: { action: string }) => { - // The authoritative current answer: access is gone. - if (action !== 'write') - return { allowed: false, status: 403, workspaceId: 'ws-1', workspacePermission: null } + mockFetchFileDocAccess // This join's own authorize saw the pre-revocation state, and the sweep records // the revocation (later read ticket) while it is still in flight. - commitRoomPermission('user-stale', room, null, beginRoomPermissionRead()) - await sleep(31_000) - return { allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'write' } - }) + .mockImplementationOnce(async () => { + commitRoomPermission('user-stale', room, null, beginRoomPermissionRead()) + await sleep(31_000) + return { allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'write' } + }) + // The authoritative current answer: access is gone. + .mockResolvedValue({ + allowed: false, + status: 403, + workspaceId: 'ws-1', + workspacePermission: null, + }) const joining = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-stale', clientId: 1 }) await vi.advanceTimersByTimeAsync(31_000) @@ -597,7 +605,7 @@ describe('setupWorkspaceFileDocHandlers', () => { await vi.advanceTimersByTimeAsync(0) // Access is downgraded to read-only, and the cached join-time decision expires. - mockAuthorizeRoom.mockResolvedValue({ + mockFetchFileDocAccess.mockResolvedValue({ allowed: false, status: 403, workspaceId: 'ws-1', @@ -663,7 +671,7 @@ describe('setupWorkspaceFileDocHandlers', () => { .filter(([event]) => event === FILE_DOC_EVENTS.PERMISSION) .map(([, payload]) => (payload as { canWrite: boolean }).canWrite) const changeRole = async (workspacePermission: 'read' | 'write') => { - mockAuthorizeRoom.mockResolvedValue({ + mockFetchFileDocAccess.mockResolvedValue({ allowed: true, status: 200, workspaceId: 'ws-1', @@ -1144,7 +1152,7 @@ describe('setupWorkspaceFileDocHandlers', () => { it('aborts a join superseded by a newer join during authorization (no cross-binding)', async () => { const { io } = createIo() let resolveFirst: (v: unknown) => void = () => {} - mockAuthorizeRoom + mockFetchFileDocAccess .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) .mockResolvedValueOnce({ allowed: true, @@ -1172,7 +1180,7 @@ describe('setupWorkspaceFileDocHandlers', () => { it('cancels an in-flight join when the client leaves that same file (no ghost owner)', async () => { const { io, sent } = createIo() let resolveAuth: (v: unknown) => void = () => {} - mockAuthorizeRoom.mockReturnValueOnce(new Promise((resolve) => (resolveAuth = resolve))) + mockFetchFileDocAccess.mockReturnValueOnce(new Promise((resolve) => (resolveAuth = resolve))) const s = setup('socket-a', io) // Join file-1 is awaiting authorization when the client leaves file-1 (fast open→close). diff --git a/apps/realtime/src/handlers/room-join-auth.ts b/apps/realtime/src/handlers/room-join-auth.ts index bc7b55748c7..f899f161a05 100644 --- a/apps/realtime/src/handlers/room-join-auth.ts +++ b/apps/realtime/src/handlers/room-join-auth.ts @@ -1,10 +1,12 @@ import type { createLogger } from '@sim/logger' -import { authorizeRoom } from '@sim/platform-authz/rooms' -import { isProjectRoom, type RoomRef } from '@sim/realtime-protocol/rooms' -import { fetchProjectRoomAccess } from '@/handlers/file-list-app' -import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' +import type { RoomRef } from '@sim/realtime-protocol/rooms' +import { + authorizeRoomAccess, + beginRoomPermissionRead, + commitRoomPermission, +} from '@/middleware/permissions' -type Authorized = Awaited> & { docId?: string | null } +type Authorized = Awaited> interface ResolveRoomJoinAuthParams { userId: string @@ -27,7 +29,7 @@ interface ResolveRoomJoinAuthParams { * Deliberately excludes the auth/readiness/id-validation preamble and the join-generation * capture/recheck — those differ per handler, and for file-doc the generation capture sits * mid-preamble. This helper is always invoked strictly between a handler's generation capture - * and its post-authorize recheck; it contains exactly the one `await authorizeRoom` that the + * and its post-authorize recheck; it contains exactly the one `await authorizeRoomAccess` that the * recheck was designed to cover and returns before any state mutation, so it never straddles * that seam. Pass each handler's own `logger` so the log namespace/request-id context is kept. */ @@ -42,15 +44,7 @@ export async function resolveRoomJoinAuth( // is never overwritten by this older result — see {@link commitRoomPermission}. const readSeq = beginRoomPermissionRead() try { - if (isProjectRoom(room)) { - if (!params.connectionId) throw new Error('Project room requires a socket identity') - authorized = await fetchProjectRoomAccess(room, { - userId, - connectionId: params.connectionId, - }) - } else { - authorized = await authorizeRoom({ userId, room, action }) - } + authorized = await authorizeRoomAccess(userId, room, action, params.connectionId) } catch (error) { logger.warn(`Error authorizing ${logLabel}:`, error) emitError({ error: messages.verifyFailed, code: 'VERIFY_ACCESS_FAILED', retryable: true }) diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index de9773d11c0..e31b67ae351 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -1,7 +1,11 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { authorizeRoom, type PermissionType } from '@sim/platform-authz/rooms' +import { + authorizeRoom, + type PermissionType, + type RoomAuthorizationResult, +} from '@sim/platform-authz/rooms' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { BLOCK_OPERATIONS, @@ -13,8 +17,15 @@ import { VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' -import { isProjectRoom, ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' +import { + isProjectRoom, + ROOM_TYPES, + type RoomRef, + roomAuthority, + roomName, +} from '@sim/realtime-protocol/rooms' import { and, eq, isNull } from 'drizzle-orm' +import { fetchFileDocAccess } from '@/handlers/file-doc-app' import { fetchProjectRoomAccess } from '@/handlers/file-list-app' const logger = createLogger('SocketPermissions') @@ -206,11 +217,29 @@ function commitRoleDecision(key: string, role: string | null, readSeq: number): return role } +/** + * Asks a non-workflow room's declared {@link roomAuthority}, at join and on every + * revalidation, so the two can never disagree. Only `workspace` rooms resolve in + * the relay; the app answers the rest. + */ +export async function authorizeRoomAccess( + userId: string, + room: RoomRef, + action: PermissionType, + connectionId?: string +): Promise { + if (isProjectRoom(room)) { + if (!connectionId) throw new Error('Project room requires a socket identity') + return fetchProjectRoomAccess(room, { userId, connectionId }) + } + if (roomAuthority(room) === 'workspace-file-doc') return fetchFileDocAccess(room.id, userId) + return authorizeRoom({ userId, room, action }) +} + /** * Reads a user's authoritative effective workspace permission for a room, or * `null` when they have none. Workflow rooms go through their own dedicated - * authorizer; every other room type resolves through the shared - * {@link authorizeRoom} (resource → owning workspace → effective permission). + * authorizer; every other room type resolves through {@link authorizeRoomAccess}. * * Always asks for the LOWEST level (`read`) so the resolved permission itself is * returned rather than a boolean — callers compare it against whatever level the @@ -223,11 +252,6 @@ async function readAuthoritativeRoomPermission( room: RoomRef, connectionId?: string ): Promise { - if (isProjectRoom(room)) { - if (!connectionId) throw new Error('Project room requires a socket identity') - const authorization = await fetchProjectRoomAccess(room, { userId, connectionId }) - return authorization.allowed ? authorization.workspacePermission : null - } if (room.type === ROOM_TYPES.WORKFLOW) { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId: room.id, @@ -237,7 +261,7 @@ async function readAuthoritativeRoomPermission( return authorization.allowed ? (authorization.workspacePermission ?? null) : null } - const authorization = await authorizeRoom({ userId, room, action: 'read' }) + const authorization = await authorizeRoomAccess(userId, room, 'read', connectionId) if (!authorization.allowed && authorization.status === 400) { throw new Error(`Room type not authorizable: ${room.type}`) } @@ -265,7 +289,7 @@ async function resolveRoleUncached( // instead of reverting to the stale join-time role. Only trust `fallbackRole` when // nothing has been recorded for this (user, workflow) yet. const lastKnown = roleCache.get(key) - if (isProjectRoom(room) || room.type === ROOM_TYPES.WORKSPACE_FILE_DOC) { + if (roomAuthority(room) !== 'workspace') { if (lastKnown?.role === null) return null throw error } diff --git a/apps/sim/app/api/internal/file-doc/access/route.ts b/apps/sim/app/api/internal/file-doc/access/route.ts new file mode 100644 index 00000000000..dddcfbceffa --- /dev/null +++ b/apps/sim/app/api/internal/file-doc/access/route.ts @@ -0,0 +1,34 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { authorizeFileDocAccessContract } from '@/lib/api/contracts/file-doc' +import { parseRequest } from '@/lib/api/server' +import { authorizeFileDocAccess } from '@/lib/collab-doc/access' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/mothership/request/http' + +/** + * POST /api/internal/file-doc/access — authorize a socket's user for a workspace file's + * collaborative document room. Internal only: gated on the shared `x-api-key: INTERNAL_API_SECRET` + * secret, matching the seed and persist endpoints the same relay calls. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = checkInternalApiKey(request) + if (!auth.success) return createUnauthorizedResponse() + + const parsed = await parseRequest(authorizeFileDocAccessContract, request, {}) + if (!parsed.success) return parsed.response + const { fileId, userId } = parsed.data.body + + const authorization = await authorizeFileDocAccess(userId, fileId) + if (!authorization.allowed || !authorization.workspaceId || !authorization.workspacePermission) { + return NextResponse.json({ + allowed: false, + status: authorization.status === 404 ? 404 : 403, + }) + } + return NextResponse.json({ + allowed: true, + workspaceId: authorization.workspaceId, + workspacePermission: authorization.workspacePermission, + }) +}) diff --git a/apps/sim/lib/api/contracts/file-doc.ts b/apps/sim/lib/api/contracts/file-doc.ts index aec578b4ca6..6e72aa889b4 100644 --- a/apps/sim/lib/api/contracts/file-doc.ts +++ b/apps/sim/lib/api/contracts/file-doc.ts @@ -1,6 +1,36 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +/** + * Internal, `x-api-key`-gated: the realtime relay asks the app whether `userId` may occupy a file's + * collaborative document room, at join and on every revalidation. The app owns the decision because + * it alone resolves the permission group that can withhold the Files module; the relay cannot. + * + * A refusal is a `200` decision rather than an HTTP status, so the relay can tell "denied" (`403`: + * no workspace access or no Files capability; `404`: the file is gone) from an app that cannot + * answer — including one deployed before this route existed, whose `404` must read as an outage + * that holds writes, not as a revocation that evicts every editor. + */ +export const authorizeFileDocAccessContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/file-doc/access', + body: z.object({ + fileId: z.string().min(1, 'fileId is required'), + userId: z.string().min(1, 'userId is required'), + }), + response: { + mode: 'json', + schema: z.discriminatedUnion('allowed', [ + z.object({ + allowed: z.literal(true), + workspaceId: z.string(), + workspacePermission: z.enum(['read', 'write', 'admin']), + }), + z.object({ allowed: z.literal(false), status: z.union([z.literal(403), z.literal(404)]) }), + ]), + }, +}) + export const buildFileDocSeedBodySchema = z.object({ workspaceId: z.string().min(1, 'workspaceId is required'), fileId: z.string().min(1, 'fileId is required'), diff --git a/apps/sim/lib/collab-doc/access.integration.ts b/apps/sim/lib/collab-doc/access.integration.ts new file mode 100644 index 00000000000..e679d42cfc2 --- /dev/null +++ b/apps/sim/lib/collab-doc/access.integration.ts @@ -0,0 +1,195 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { db } from '@sim/db' +import { + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + user, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { featureFlagsMock } from '@sim/testing/mocks/feature-flags.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterAll, beforeAll, expect, it, vi } from 'vitest' +import { authorizeFileDocAccess } from '@/lib/collab-doc/access' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { POST } from '@/app/api/internal/file-doc/access/route' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const ownerId = generateId() +const memberId = generateId() +const outsiderId = generateId() +const organizationId = generateId() +const workspaceId = generateId() +const fileId = generateId() +const groupId = generateId() +const checks: { name: string; status: string; durationMs: number; error?: string }[] = [] + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function setFilesWithheld(withheld: boolean) { + await db + .update(permissionGroup) + .set({ config: withheld ? { hideFilesTab: true } : {} }) + .where(eq(permissionGroup.id, groupId)) +} + +beforeAll(async () => { + await db.insert(user).values( + [ownerId, memberId, outsiderId].map((id) => ({ + id, + name: 'Document access fixture', + email: `${id}@doc-access.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Document access', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { id: generateId(), organizationId, userId: memberId, role: 'member', createdAt: new Date() }, + ]) + await insertWorkspaceFixture(db, { + id: workspaceId, + organizationId, + ownerId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', + name: 'Document access', + }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: workspaceId, + userId: memberId, + permissionType: 'write', + }) + await db.insert(workspaceFiles).values({ + id: fileId, + key: `workspace/${workspaceId}/${fileId}-notes.md`, + userId: ownerId, + workspaceId, + context: 'workspace', + originalName: 'notes.md', + contentType: 'text/markdown', + }) + await db.insert(permissionGroup).values({ + id: groupId, + organizationId, + createdBy: ownerId, + name: 'Files fixture', + config: {}, + membershipMode: 'inherit', + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId, + organizationId, + }) +}) + +afterAll(async () => { + const reportPath = + process.env.FILE_DOC_ACCESS_REPORT_PATH ?? resolve('test-results/file-doc-access.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + await db.delete(workspaceFiles).where(eq(workspaceFiles.id, fileId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [ownerId, memberId, outsiderId])) +}) + +check( + 'withholding Files refuses the room exactly where the HTTP file operations refuse', + async () => { + await setFilesWithheld(true) + await expect( + readWorkspaceFileMetadata.execute({ + principal: createSessionPrincipal({ userId: memberId }), + input: { fileId }, + }) + ).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + expect(await authorizeFileDocAccess(memberId, fileId)).toMatchObject({ + allowed: false, + status: 403, + workspaceId, + workspacePermission: null, + }) + } +) + +check('a non-member is refused and a missing file is not found', async () => { + await setFilesWithheld(false) + expect(await authorizeFileDocAccess(outsiderId, fileId)).toMatchObject({ + allowed: false, + status: 403, + workspacePermission: null, + }) + expect(await authorizeFileDocAccess(memberId, generateId())).toMatchObject({ + allowed: false, + status: 404, + }) +}) + +check( + 'the relay route refuses without the secret and returns the decision in its body', + async () => { + const request = (secret: string | undefined) => + POST( + new NextRequest('http://localhost:3000/api/internal/file-doc/access', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(secret ? { 'x-api-key': secret } : {}), + }, + body: JSON.stringify({ fileId, userId: memberId }), + }) + ) + const secret = process.env.INTERNAL_API_SECRET + expect((await request(undefined)).status).toBe(401) + await setFilesWithheld(true) + const refused = await request(secret) + expect(refused.status).toBe(200) + expect(await refused.json()).toEqual({ allowed: false, status: 403 }) + await setFilesWithheld(false) + const allowed = await request(secret) + expect(allowed.status).toBe(200) + expect(await allowed.json()).toEqual({ + allowed: true, + workspaceId, + workspacePermission: 'write', + }) + } +) diff --git a/apps/sim/lib/collab-doc/access.ts b/apps/sim/lib/collab-doc/access.ts new file mode 100644 index 00000000000..c5f8a108b3b --- /dev/null +++ b/apps/sim/lib/collab-doc/access.ts @@ -0,0 +1,37 @@ +import { authorizeRoom, type RoomAuthorizationResult } from '@sim/platform-authz/rooms' +import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { + capabilityRefusal, + isWorkspaceCapabilityWithheld, +} from '@/lib/permission-groups/capability-assertions' + +/** + * Whether `userId` may occupy a workspace file's collaborative document room. + * + * The room streams the file's content to every member and persists their edits, so it answers to + * the Files capability exactly as the HTTP file operations do. A withheld capability carries no + * permission, because the relay caches whatever permission a refusal reports and its write gate + * reads that cache. + */ +export async function authorizeFileDocAccess( + userId: string, + fileId: string +): Promise { + const authorization = await authorizeRoom({ + userId, + room: { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId }, + }) + if (!authorization.allowed || !authorization.workspaceId) return authorization + + // permission-group-enforced: files.use — the document room carries the file's content both ways. + if (await isWorkspaceCapabilityWithheld(userId, authorization.workspaceId, 'files.use')) { + return { + allowed: false, + status: 403, + message: capabilityRefusal('files.use'), + workspaceId: authorization.workspaceId, + workspacePermission: null, + } + } + return authorization +} diff --git a/packages/realtime-protocol/src/rooms.ts b/packages/realtime-protocol/src/rooms.ts index 53b4eb663a8..03a12a110a5 100644 --- a/packages/realtime-protocol/src/rooms.ts +++ b/packages/realtime-protocol/src/rooms.ts @@ -90,24 +90,36 @@ export interface RoomRef { id: string } +/** + * `workspace` rooms gate on the workspace role alone, which the relay reads itself. The other + * authorities belong to the app: a workspace file document also answers to the permission group's + * Files capability, and a Project room to Project membership. + */ +type RoomAuthority = 'workspace' | 'workspace-file-doc' | 'project' + const ROOM_AUTHORIZATION_OWNERS = { [ROOM_TYPES.WORKFLOW]: 'workspace', [ROOM_TYPES.WORKSPACE_FILES]: 'workspace', [ROOM_TYPES.WORKSPACE_TABLES]: 'workspace', [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'workspace', - [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'workspace', + [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'workspace-file-doc', [ROOM_TYPES.TABLE]: 'workspace', [ROOM_TYPES.PROJECT_FILE_DOC]: 'project', [ROOM_TYPES.PROJECT_FILES]: 'project', -} as const satisfies Record +} as const satisfies Record + +/** Every room declares the authority that both admission and revalidation must consult. */ +export function roomAuthority(room: RoomRef): RoomAuthority { + return ROOM_AUTHORIZATION_OWNERS[room.type] +} export type ProjectRoomRef = RoomRef & { type: typeof ROOM_TYPES.PROJECT_FILE_DOC | typeof ROOM_TYPES.PROJECT_FILES } -/** Every room declares the authority that both admission and revalidation must consult. */ +/** Narrows a room whose authority is Project membership. */ export function isProjectRoom(room: RoomRef): room is ProjectRoomRef { - return ROOM_AUTHORIZATION_OWNERS[room.type] === 'project' + return roomAuthority(room) === 'project' } /** Type guard: whether an arbitrary string is a known {@link RoomType}. */ From fa3f68b75eff8757c1caadab3f7bd65faff22ed2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 12:43:31 -0800 Subject: [PATCH 2/2] fix(realtime): pass the route context in the document access integration check --- apps/sim/lib/collab-doc/access.integration.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/collab-doc/access.integration.ts b/apps/sim/lib/collab-doc/access.integration.ts index e679d42cfc2..eb8d11b5e3a 100644 --- a/apps/sim/lib/collab-doc/access.integration.ts +++ b/apps/sim/lib/collab-doc/access.integration.ts @@ -175,7 +175,8 @@ check( ...(secret ? { 'x-api-key': secret } : {}), }, body: JSON.stringify({ fileId, userId: memberId }), - }) + }), + { params: Promise.resolve({}) } ) const secret = process.env.INTERNAL_API_SECRET expect((await request(undefined)).status).toBe(401)