Skip to content

Commit 950d8a0

Browse files
committed
fix(realtime): order role-cache writes by read start, not write time
Two authorizations can start in one order and finish in the other, so the decision written last can come from the older read. A join that authorized before a revocation but returned after the sweep's denial would bury it, handing the socket another full cache TTL of access. Every writer now takes a monotonic ticket before it queries and yields only to a later-started read.
1 parent 28192ff commit 950d8a0

3 files changed

Lines changed: 116 additions & 59 deletions

File tree

apps/realtime/src/handlers/room-join-auth.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { createLogger } from '@sim/logger'
22
import { authorizeRoom } from '@sim/platform-authz/rooms'
33
import type { RoomRef } from '@sim/realtime-protocol/rooms'
4-
import { recordRoomPermissionIfUnchanged, snapshotRoomPermission } from '@/middleware/permissions'
4+
import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions'
55

66
type Authorized = Awaited<ReturnType<typeof authorizeRoom>>
77

@@ -35,10 +35,10 @@ export async function resolveRoomJoinAuth(
3535
const { userId, room, action, logger, logLabel, messages, emitError } = params
3636

3737
let authorized: Authorized
38-
// Captured before the query so a decision recorded WHILE it was in flight (the
39-
// access-revalidation sweep evicting this user) is never overwritten by this
40-
// older read — see {@link recordRoomPermissionIfUnchanged}.
41-
const snapshot = snapshotRoomPermission(userId, room)
38+
// Taken before the query so this read is ordered against every other one: a
39+
// decision from a later-started read (the access-revalidation sweep's denial)
40+
// is never overwritten by this older result — see {@link commitRoomPermission}.
41+
const readSeq = beginRoomPermissionRead()
4242
try {
4343
authorized = await authorizeRoom({ userId, room, action })
4444
} catch (error) {
@@ -54,7 +54,7 @@ export async function resolveRoomJoinAuth(
5454
// not authorizable here) resolved no permission at all and is deliberately not
5555
// recorded. A 404 records `null`: the resource is genuinely gone.
5656
if (authorized.status !== 400) {
57-
recordRoomPermissionIfUnchanged(userId, room, authorized.workspacePermission, snapshot)
57+
commitRoomPermission(userId, room, authorized.workspacePermission, readSeq)
5858
}
5959

6060
if (!authorized.allowed) {

apps/realtime/src/middleware/permissions.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,43 @@ describe('verifyWorkflowAccess role-cache refresh', () => {
519519
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write')
520520
})
521521

522+
it('does not let a stale join-time allow bury a sweep denial that started later', async () => {
523+
const userId = 'vw-user-3'
524+
const workflowId = 'vw-wf-3'
525+
526+
// Hand out a controllable promise per authorization call, so the two reads can be
527+
// started in one order and settled in the other.
528+
const settle: Array<(value: { allowed: boolean; workspacePermission: string | null }) => void> =
529+
[]
530+
mockAuthorize.mockImplementation(
531+
() =>
532+
new Promise((resolve) => {
533+
settle.push(resolve)
534+
})
535+
)
536+
537+
// A join-style verify starts FIRST (against pre-revocation state) and stalls.
538+
const staleJoin = verifyWorkflowAccess(userId, workflowId)
539+
for (let i = 0; i < 10 && settle.length < 1; i++) await Promise.resolve()
540+
expect(settle).toHaveLength(1)
541+
542+
// The sweep's authorization starts AFTER it, and stalls too.
543+
const sweep = resolveCurrentWorkflowRole(userId, workflowId, 'read')
544+
for (let i = 0; i < 10 && settle.length < 2; i++) await Promise.resolve()
545+
expect(settle).toHaveLength(2)
546+
547+
// The sweep's denial lands first, then the older join's allow. Ordering by WRITE
548+
// time would let the join bury the denial and hand the socket another full TTL of
549+
// access; ordering by read start keeps the denial in force.
550+
settle[1]({ allowed: false, workspacePermission: null })
551+
expect(await sweep).toBeNull()
552+
settle[0]({ allowed: true, workspacePermission: 'write' })
553+
await staleJoin
554+
555+
mockAuthorize.mockRejectedValue(new Error('must not re-query'))
556+
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBeNull()
557+
})
558+
522559
it('does not let a stale in-flight resolution overwrite a fresher verify decision', async () => {
523560
const userId = 'vw-user-2'
524561
const workflowId = 'vw-wf-2'

apps/realtime/src/middleware/permissions.ts

Lines changed: 73 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,34 @@ const MAX_ROLE_CACHE_ENTRIES = 5_000
102102
interface CachedRole {
103103
/** Authoritative workspace role, or `null` when the user has no access. */
104104
role: string | null
105+
/**
106+
* The {@link beginRoomPermissionRead} ticket of the query this decision came
107+
* from — i.e. when its DB read STARTED, not when it was written.
108+
*/
109+
readSeq: number
105110
expiresAt: number
106111
}
107112

113+
/**
114+
* Monotonic ticket counter establishing a total order over authorization READS.
115+
*
116+
* Ordering by write time is not sufficient: two readers can start their queries in
117+
* one order and finish in the other, so the decision written last can be derived
118+
* from the older read. A join that authorized before a revocation but returned
119+
* after the sweep's denial would then win — leaving a revoked user with another
120+
* full TTL of access. Every writer therefore takes a ticket before it queries and
121+
* a decision only yields to one from a strictly later-started read.
122+
*/
123+
let roleReadSeqCounter = 0
124+
125+
/**
126+
* Takes a read ticket. Call immediately BEFORE issuing an authorization query, and
127+
* pass the result to {@link commitRoomPermission} once it returns.
128+
*/
129+
export function beginRoomPermissionRead(): number {
130+
return ++roleReadSeqCounter
131+
}
132+
108133
/**
109134
* Per-pod cache of authoritative workspace roles, keyed by
110135
* `${userId}:${roomName(room)}`.
@@ -142,19 +167,31 @@ function purgeExpiredRoles(now: number): void {
142167
}
143168
}
144169

145-
/**
146-
* Records a freshly-read authoritative decision into the role cache. Every
147-
* successful DB read of a user's workspace role goes through this — including
148-
* the join-time {@link verifyWorkflowAccess} — so a stale cached revocation
149-
* never outlives a newer authoritative read (e.g. a re-granted user re-joining
150-
* within the TTL of the sweep's recorded `null`).
151-
*/
152-
function recordRoleDecision(key: string, role: string | null): void {
170+
function recordRoleDecision(key: string, role: string | null, readSeq: number): void {
153171
const now = Date.now()
154172
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
155173
purgeExpiredRoles(now)
156174
}
157-
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
175+
roleCache.set(key, { role, readSeq, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
176+
}
177+
178+
/**
179+
* Commits a freshly-read authoritative decision, unless a decision from a
180+
* strictly later-started read is already cached — in which case that one stands
181+
* and is returned instead.
182+
*
183+
* Every successful DB read of a user's workspace role goes through this: the
184+
* cached resolver, the join-time {@link verifyWorkflowAccess}, and the room join
185+
* authorizer. That is what keeps a stale cached revocation from outliving a newer
186+
* authoritative read (a re-granted user re-joining inside the sweep's recorded
187+
* `null` TTL) while equally keeping a stale ALLOW from burying the sweep's fresher
188+
* denial. Returns the decision that ends up in force.
189+
*/
190+
function commitRoleDecision(key: string, role: string | null, readSeq: number): string | null {
191+
const existing = roleCache.get(key)
192+
if (existing !== undefined && existing.readSeq > readSeq) return existing.role
193+
recordRoleDecision(key, role, readSeq)
194+
return role
158195
}
159196

160197
/**
@@ -195,19 +232,13 @@ async function resolveRoleUncached(
195232
room: RoomRef,
196233
fallbackRole: string
197234
): Promise<string | null> {
198-
const entryBeforeQuery = roleCache.get(key)
235+
const readSeq = beginRoomPermissionRead()
199236
try {
200237
const role = await readAuthoritativeRoomPermission(userId, room)
201-
// A fresh authoritative read (e.g. a join-time verifyWorkflowAccess) may
202-
// have recorded a decision while this query was in flight. That write is
203-
// newer than this query's read snapshot, so prefer it instead of
204-
// overwriting it with a potentially stale result.
205-
const entryAfterQuery = roleCache.get(key)
206-
if (entryAfterQuery !== undefined && entryAfterQuery !== entryBeforeQuery) {
207-
return entryAfterQuery.role
208-
}
209-
recordRoleDecision(key, role)
210-
return role
238+
// Yields only to a decision from a later-STARTED read (see commitRoleDecision).
239+
// Comparing write order instead would let a join whose authorize began before
240+
// this one — but returned after it — bury this result.
241+
return commitRoleDecision(key, role, readSeq)
211242
} catch (error) {
212243
logger.warn(
213244
`Failed to re-validate role for user ${userId} on ${roomName(room)}; using last known role`,
@@ -291,50 +322,35 @@ export function peekRoomPermission(userId: string, room: RoomRef): string | null
291322
}
292323

293324
/**
294-
* Records an authoritative decision read outside this module (the room join
325+
* Commits an authoritative decision read outside this module (the room join
295326
* authorizer) into the shared cache, so the sweep and the per-frame gates start
296-
* warm and a re-granted user is never held out by a stale cached revocation.
327+
* warm on the room a socket just joined.
297328
*
298-
* Unconditional: the caller's read is taken as the newest word. Use
299-
* {@link recordRoomPermissionIfUnchanged} when the read was slow enough that a
300-
* newer decision could have landed meanwhile.
329+
* `readSeq` must come from a {@link beginRoomPermissionRead} taken BEFORE the
330+
* caller's query: the decision is discarded if a later-started read already
331+
* committed one, so neither a stale ALLOW can bury the sweep's fresher denial nor
332+
* a stale revocation outlive a re-grant.
301333
*/
302-
export function recordRoomPermission(
334+
export function commitRoomPermission(
303335
userId: string,
304336
room: RoomRef,
305-
permission: PermissionType | null
337+
permission: PermissionType | null,
338+
readSeq: number
306339
): void {
307-
recordRoleDecision(roleCacheKey(userId, room), permission)
308-
}
309-
310-
/** Opaque token identifying the cache entry a caller observed before its query. */
311-
export type RoomPermissionSnapshot = object | undefined
312-
313-
/** Captures the current cache entry for a (user, room), for {@link recordRoomPermissionIfUnchanged}. */
314-
export function snapshotRoomPermission(userId: string, room: RoomRef): RoomPermissionSnapshot {
315-
return roleCache.get(roleCacheKey(userId, room))
340+
commitRoleDecision(roleCacheKey(userId, room), permission, readSeq)
316341
}
317342

318343
/**
319-
* Records an authoritative decision only if no other decision landed since
320-
* `snapshot` was taken.
321-
*
322-
* Without this, a join whose authorization query started before a revocation but
323-
* returned after the sweep recorded that revocation would overwrite it with its own
324-
* stale "allowed", handing the socket another full TTL of access. Entry identity
325-
* (not a timestamp) is the comparison, mirroring the in-flight ordering guard the
326-
* cached resolver already applies to its own queries — so it is exact regardless of
327-
* how many writes land inside one millisecond.
344+
* Records a decision as the newest word, taking its read ticket at write time.
345+
* For callers that have just observed the authoritative state with no query of
346+
* their own to order against.
328347
*/
329-
export function recordRoomPermissionIfUnchanged(
348+
export function recordRoomPermission(
330349
userId: string,
331350
room: RoomRef,
332-
permission: PermissionType | null,
333-
snapshot: RoomPermissionSnapshot
351+
permission: PermissionType | null
334352
): void {
335-
const key = roleCacheKey(userId, room)
336-
if (roleCache.get(key) !== snapshot) return
337-
recordRoleDecision(key, permission)
353+
recordRoleDecision(roleCacheKey(userId, room), permission, beginRoomPermissionRead())
338354
}
339355

340356
/**
@@ -376,6 +392,9 @@ export async function verifyWorkflowAccess(
376392
userId: string,
377393
workflowId: string
378394
): Promise<{ hasAccess: boolean; role?: string; workspaceId?: string }> {
395+
// Taken before the reads below, so this decision yields to any authorization
396+
// that started later (e.g. the sweep's denial) instead of by write order.
397+
const readSeq = beginRoomPermissionRead()
379398
try {
380399
const workflowData = await db
381400
.select({
@@ -398,9 +417,10 @@ export async function verifyWorkflowAccess(
398417
action: 'read',
399418
})
400419

401-
recordRoleDecision(
420+
commitRoleDecision(
402421
roleCacheKey(userId, { type: ROOM_TYPES.WORKFLOW, id: workflowId }),
403-
authorization.allowed ? (authorization.workspacePermission ?? null) : null
422+
authorization.allowed ? (authorization.workspacePermission ?? null) : null,
423+
readSeq
404424
)
405425

406426
if (!authorization.allowed || !authorization.workspacePermission) {

0 commit comments

Comments
 (0)