Skip to content

Commit 7323300

Browse files
committed
fix(realtime): re-resolve access at join commit instead of peeking the cache
The pre-commit recheck peeked the role cache, which reports an EXPIRED entry as unknown and fails open — so a join stalled longer than the cache TTL could re-enter a room the sweep had already evicted it from, including a file-doc room where the next cold-cache frame is accepted as a durable write. All three joins now re-resolve the way the workflow join always has; it is normally a cache hit, since the join's own authorize just warmed it.
1 parent 1086dc3 commit 7323300

4 files changed

Lines changed: 74 additions & 27 deletions

File tree

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,43 @@ describe('setupWorkspaceFileDocHandlers', () => {
275275
expect(joinSuccessFileId(socket)).toBeUndefined()
276276
})
277277

278+
it('re-reads access when the cached decision expired mid-join, instead of failing open', async () => {
279+
// A join stalled longer than the cache TTL: the sweep's denial is recorded with a
280+
// later read ticket (so this join's own allow is correctly dropped) but has since
281+
// expired. Peeking the cache would read that as "unknown" and let the socket back
282+
// into the document, so the join must re-resolve against the database.
283+
vi.useFakeTimers()
284+
try {
285+
const room = { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-stale' }
286+
const { io } = createIo()
287+
const { socket, handlers } = setup('socket-stale', io, { userId: 'user-stale' })
288+
289+
mockAuthorizeRoom.mockImplementation(async ({ action }: { action: string }) => {
290+
// The authoritative current answer: access is gone.
291+
if (action !== 'write')
292+
return { allowed: false, status: 403, workspaceId: 'ws-1', workspacePermission: null }
293+
// This join's own authorize saw the pre-revocation state, and the sweep records
294+
// the revocation (later read ticket) while it is still in flight.
295+
commitRoomPermission('user-stale', room, null, beginRoomPermissionRead())
296+
await new Promise((resolve) => setTimeout(resolve, 31_000))
297+
return { allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'write' }
298+
})
299+
300+
const joining = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-stale', clientId: 1 })
301+
await vi.advanceTimersByTimeAsync(31_000)
302+
await joining
303+
304+
expect(socket.emit).toHaveBeenCalledWith(
305+
FILE_DOC_EVENTS.JOIN_ERROR,
306+
expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false })
307+
)
308+
expect(socket.join).not.toHaveBeenCalled()
309+
expect(joinSuccessFileId(socket)).toBeUndefined()
310+
} finally {
311+
vi.useRealTimers()
312+
}
313+
})
314+
278315
it('requires write permission and reports 404 as NOT_FOUND', async () => {
279316
mockAuthorizeRoom.mockResolvedValue({ allowed: false, status: 404, workspacePermission: null })
280317
const { io } = createIo()

apps/realtime/src/handlers/file-doc.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,25 +1091,27 @@ export function setupWorkspaceFileDocHandlers(
10911091
// awareness). Resolved here so the generation guard below also covers this await.
10921092
const avatarUrl = await resolveAvatarUrl(socket, userId)
10931093

1094+
// Re-check access immediately before registering, mirroring the workflow join: the
1095+
// access re-validation sweep records a revocation BEFORE it evicts, so a join that
1096+
// authorized just before the revocation must not complete afterwards and re-bind
1097+
// the socket to the document. This RE-RESOLVES rather than peeking the cache — a
1098+
// peek treats an expired entry as unknown and fails open, which a join stalled
1099+
// longer than the cache TTL would slip straight through. Normally a cache hit (this
1100+
// join's own authorize just warmed it), so it costs no extra query.
1101+
const currentPermission = await resolveCurrentRoomPermission(userId, room, FILE_DOC_ACTION)
1102+
if (!satisfiesRoomMembership(currentPermission, ROOM_TYPES.WORKSPACE_FILE_DOC)) {
1103+
logger.warn(`User ${userId} lost write access to file ${fileId} before the join completed`)
1104+
emitJoinError(socket, fileId, 'Access denied to file', 'ACCESS_DENIED', false)
1105+
return
1106+
}
1107+
10941108
// Abort a JOIN superseded during authorization/identity resolution: the socket
10951109
// disconnected, or a newer JOIN (a document switch) bumped the generation. Registering
10961110
// here would leak a dead socket's room or bind the socket to the wrong document.
1111+
// Last await before the commit, so nothing can interleave between the access
1112+
// re-check above and the registration below.
10971113
if (socket.disconnected || joinGeneration.get(socket.id) !== generation) return
10981114

1099-
// Re-check the cached decision immediately before registering: the access
1100-
// re-validation sweep records a revocation BEFORE it evicts, so a join that
1101-
// authorized just before the revocation cannot complete afterwards and re-bind
1102-
// the socket to the document. `undefined` (nothing cached) is "unknown", never a
1103-
// denial — the authorize above is then the freshest word we have.
1104-
const recheck = peekRoomPermission(userId, room)
1105-
if (
1106-
recheck !== undefined &&
1107-
!satisfiesRoomMembership(recheck, ROOM_TYPES.WORKSPACE_FILE_DOC)
1108-
) {
1109-
emitJoinError(socket, fileId, 'Access denied to file', 'ACCESS_DENIED', false)
1110-
return
1111-
}
1112-
11131115
const entry = getOrCreateRoom(io, room)
11141116

11151117
// A client id must be owned by at most one user, or a peer could bind an active

apps/realtime/src/handlers/tables.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,14 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
260260
// awaits above bumped the generation, or the socket disconnected. Abort before registering.
261261
if (superseded()) return
262262

263-
// Re-check the cached decision too: the access re-validation sweep records a
264-
// revocation BEFORE it evicts, so a join that authorized just before the
265-
// revocation cannot complete afterwards and put the socket back in the room.
266-
// `undefined` (nothing cached) is "unknown", never a denial.
267-
const recheck = peekRoomPermission(userId, room)
268-
if (recheck !== undefined && !satisfiesRoomMembership(recheck, ROOM_TYPES.TABLE)) {
263+
// Re-check access too: the access re-validation sweep records a revocation BEFORE
264+
// it evicts, so a join that authorized just before the revocation must not
265+
// complete afterwards and put the socket back in the room. RE-RESOLVES rather
266+
// than peeking — a peek treats an expired entry as unknown and fails open, which
267+
// a join stalled longer than the cache TTL would slip through. Normally a cache
268+
// hit (this join's own authorize just warmed it).
269+
const currentPermission = await resolveCurrentRoomPermission(userId, room, TABLE_ACTION)
270+
if (!satisfiesRoomMembership(currentPermission, ROOM_TYPES.TABLE)) {
269271
socket.emit(TABLE_PRESENCE_EVENTS.JOIN_ERROR, {
270272
tableId,
271273
error: 'Access denied to table',

apps/realtime/src/handlers/workspace-invalidation-room.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-
33
import { type RoomRef, type RoomType, roomName } from '@sim/realtime-protocol/rooms'
44
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
55
import type { AuthenticatedSocket } from '@/middleware/auth'
6-
import { peekRoomPermission } from '@/middleware/permissions'
6+
import { resolveCurrentRoomPermission } from '@/middleware/permissions'
77
import type { IRoomManager } from '@/rooms'
88

99
const logger = createLogger('WorkspaceInvalidationRoom')
@@ -108,13 +108,19 @@ export function setupWorkspaceInvalidationRoom(
108108
// stale join can't leave the room the client has since switched to.
109109
if (joinGeneration !== joinAttempt || socket.disconnected) return
110110

111-
// Re-check the cached decision before committing: the access re-validation sweep
112-
// records a revocation BEFORE it evicts, so a join that authorized just before the
111+
// Re-check access before committing: the access re-validation sweep records a
112+
// revocation BEFORE it evicts, so a join that authorized just before the
113113
// revocation must not complete afterwards and put the socket back in the room.
114-
// `undefined` (nothing cached) is "unknown", never a denial — the authorize above
115-
// is then the freshest word we have. Mirrors the file-doc and table joins.
116-
const recheck = peekRoomPermission(socket.userId, ref)
117-
if (recheck !== undefined && !satisfiesRoomMembership(recheck, roomType)) {
114+
// RE-RESOLVES rather than peeking — a peek treats an expired entry as unknown and
115+
// fails open, which a join stalled longer than the cache TTL would slip through.
116+
// Normally a cache hit (this join's own authorize just warmed it). Mirrors the
117+
// file-doc and table joins.
118+
const currentPermission = await resolveCurrentRoomPermission(
119+
socket.userId,
120+
ref,
121+
ROOM_MEMBERSHIP_ACTIONS[roomType]
122+
)
123+
if (!satisfiesRoomMembership(currentPermission, roomType)) {
118124
socket.emit(errorEvent, {
119125
workspaceId,
120126
error: 'Access denied to workspace',

0 commit comments

Comments
 (0)