Skip to content

Commit 1619fda

Browse files
icecrasher321claude
andcommitted
fix(realtime): prefer a mid-flight recorded role decision over the in-flight query result
If a fresh authoritative read (join-time verify) records a decision while a single-flighted resolution's query is in flight, keep the recorded decision instead of overwriting it with the potentially stale result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent aa385d0 commit 1619fda

2 files changed

Lines changed: 38 additions & 0 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,33 @@ describe('verifyWorkflowAccess role-cache refresh', () => {
518518
mockAuthorize.mockRejectedValue(new Error('must not re-query'))
519519
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write')
520520
})
521+
522+
it('does not let a stale in-flight resolution overwrite a fresher verify decision', async () => {
523+
const userId = 'vw-user-2'
524+
const workflowId = 'vw-wf-2'
525+
526+
// A sweep-style resolution starts against pre-re-grant state and stalls.
527+
let resolveStaleQuery!: (value: {
528+
allowed: boolean
529+
workspacePermission: string | null
530+
}) => void
531+
mockAuthorize.mockReturnValueOnce(
532+
new Promise((resolve) => {
533+
resolveStaleQuery = resolve
534+
})
535+
)
536+
const staleResolution = resolveCurrentWorkflowRole(userId, workflowId, 'read')
537+
538+
// Access is re-granted and a fresh join-time verify records it mid-flight.
539+
mockAuthorize.mockResolvedValueOnce({ allowed: true, workspacePermission: 'write' })
540+
await verifyWorkflowAccess(userId, workflowId)
541+
542+
// The stale query now completes with the pre-re-grant revocation — it must
543+
// yield to the fresher recorded decision, not overwrite it.
544+
resolveStaleQuery({ allowed: false, workspacePermission: null })
545+
expect(await staleResolution).toBe('write')
546+
547+
mockAuthorize.mockRejectedValue(new Error('must not re-query'))
548+
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write')
549+
})
521550
})

apps/realtime/src/middleware/permissions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,22 @@ async function resolveRoleUncached(
150150
workflowId: string,
151151
fallbackRole: string
152152
): Promise<string | null> {
153+
const entryBeforeQuery = roleCache.get(key)
153154
try {
154155
const authorization = await authorizeWorkflowByWorkspacePermission({
155156
workflowId,
156157
userId,
157158
action: 'read',
158159
})
159160
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
161+
// A fresh authoritative read (e.g. a join-time verifyWorkflowAccess) may
162+
// have recorded a decision while this query was in flight. That write is
163+
// newer than this query's read snapshot, so prefer it instead of
164+
// overwriting it with a potentially stale result.
165+
const entryAfterQuery = roleCache.get(key)
166+
if (entryAfterQuery !== undefined && entryAfterQuery !== entryBeforeQuery) {
167+
return entryAfterQuery.role
168+
}
160169
recordRoleDecision(key, role)
161170
return role
162171
} catch (error) {

0 commit comments

Comments
 (0)