Skip to content
Merged
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
37 changes: 37 additions & 0 deletions apps/realtime/src/handlers/file-doc-app.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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<RoomAuthorizationResult> {
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.
Expand Down
88 changes: 75 additions & 13 deletions apps/realtime/src/handlers/file-doc-capabilities.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string>()

async function listen(server: HttpServer) {
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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, {
Expand All @@ -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<void>((done) => io.close(() => done()))
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/handlers/file-doc.multireplica.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
17 changes: 10 additions & 7 deletions apps/realtime/src/handlers/file-doc.redis-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
58 changes: 33 additions & 25 deletions apps/realtime/src/handlers/file-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -213,7 +216,7 @@ function joinSuccessFileId(socket: { emit: ReturnType<typeof vi.fn> }) {

describe('setupWorkspaceFileDocHandlers', () => {
beforeEach(() => {
mockAuthorizeRoom.mockResolvedValue({
mockFetchFileDocAccess.mockResolvedValue({
allowed: true,
status: 200,
workspaceId: 'ws-1',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
24 changes: 9 additions & 15 deletions apps/realtime/src/handlers/room-join-auth.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof authorizeRoom>> & { docId?: string | null }
type Authorized = Awaited<ReturnType<typeof authorizeRoomAccess>>

interface ResolveRoomJoinAuthParams {
userId: string
Expand All @@ -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.
*/
Expand All @@ -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 })
Expand Down
Loading
Loading