Skip to content

Commit 21c5e8d

Browse files
committed
address comments
1 parent 5996aa2 commit 21c5e8d

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,12 +365,52 @@ describe('checkWorkflowOperationPermission', () => {
365365
}
366366
})
367367

368-
it('falls back to the last known role on a transient DB error', async () => {
368+
it('falls back to the join-time role on a transient DB error when nothing is cached yet', async () => {
369369
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
370370

371371
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
372372

373373
expect(result.allowed).toBe(true)
374374
expect(result.role).toBe('write')
375375
})
376+
377+
it('preserves a recorded revocation through a later transient DB error', async () => {
378+
vi.useFakeTimers()
379+
try {
380+
// First check records the revocation (null) in the cache
381+
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
382+
const first = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'admin')
383+
expect(first.allowed).toBe(false)
384+
expect(first.role).toBeNull()
385+
386+
// TTL expires, then the DB blips on the next re-validation. The stale join-time
387+
// role ('admin') must NOT resurrect access — the recorded revocation wins.
388+
vi.advanceTimersByTime(31_000)
389+
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
390+
391+
const second = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'admin')
392+
expect(second.allowed).toBe(false)
393+
expect(second.role).toBeNull()
394+
} finally {
395+
vi.useRealTimers()
396+
}
397+
})
398+
399+
it('uses the last cached role (not the join-time role) on a transient DB error', async () => {
400+
vi.useFakeTimers()
401+
try {
402+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
403+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
404+
405+
vi.advanceTimersByTime(31_000)
406+
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
407+
408+
// fallbackRole is 'read', but the last recorded decision was 'write' — use that
409+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
410+
expect(result.allowed).toBe(true)
411+
expect(result.role).toBe('write')
412+
} finally {
413+
vi.useRealTimers()
414+
}
415+
})
376416
})

apps/realtime/src/middleware/permissions.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,10 @@ function purgeExpiredRoles(now: number): void {
122122
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
123123
*
124124
* Returns `null` when the user genuinely has no access (removed/revoked). On a transient
125-
* DB failure it falls back to `fallbackRole` so a blip does not block legitimate editors
126-
* — the subsequent persist would fail on its own if the database is truly unavailable.
125+
* DB failure it reuses the last recorded decision for this (user, workflow) — including a
126+
* previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
127+
* decision has been recorded yet, so a blip neither blocks legitimate editors nor
128+
* resurrects already-revoked access.
127129
*/
128130
export async function resolveCurrentWorkflowRole(
129131
userId: string,
@@ -154,7 +156,12 @@ export async function resolveCurrentWorkflowRole(
154156
`Failed to re-validate role for user ${userId} on workflow ${workflowId}; using last known role`,
155157
error
156158
)
157-
return fallbackRole
159+
// Prefer the last recorded decision — even if expired, and even if it is `null` for an
160+
// already-revoked user — so a recorded revocation survives a transient DB failure
161+
// instead of reverting to the stale join-time role. Only trust `fallbackRole` when
162+
// nothing has been recorded for this (user, workflow) yet.
163+
const lastKnown = roleCache.get(key)
164+
return lastKnown !== undefined ? lastKnown.role : fallbackRole
158165
}
159166
}
160167

0 commit comments

Comments
 (0)