Skip to content

Commit aa385d0

Browse files
icecrasher321claude
andcommitted
fix(realtime): treat unconfirmed removals as failures and refresh role cache on fresh verify
Treat any null removal result as a failed cleanup (the sweep always passes the target room, so null only means failure — including with expired mapping keys), move the same-room rejoin guard to a synchronous check immediately before the removal, and record verifyWorkflowAccess's fresh decision into the role cache so a re-granted user is not blocked by a stale cached revocation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ca3b4d2 commit aa385d0

4 files changed

Lines changed: 115 additions & 25 deletions

File tree

apps/realtime/src/access-revalidation.test.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,16 @@ interface FakeSocket {
3030
function makeSocket(id: string, userId: string | undefined, workflowId?: string): FakeSocket {
3131
const rooms = new Set<string>([id])
3232
if (workflowId) rooms.add(workflowId)
33-
return { id, userId, rooms, emit: vi.fn(), leave: vi.fn() }
33+
return {
34+
id,
35+
userId,
36+
rooms,
37+
emit: vi.fn(),
38+
// Socket.IO's leave removes the room from `rooms` synchronously.
39+
leave: vi.fn((room: string) => {
40+
rooms.delete(room)
41+
}),
42+
}
3443
}
3544

3645
function makeManager(sockets: FakeSocket[], presence: Partial<UserPresence>[] = []) {
@@ -179,9 +188,29 @@ describe('access-revalidation sweep', () => {
179188
expect(socket.leave).toHaveBeenCalledWith('wf-1')
180189
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
181190

182-
// The evicted socket is out of the room now, so membership scans no longer
183-
// see it — the retry queue must drive the cleanup to completion.
184-
socket.rooms = new Set(['sock-1'])
191+
// The evicted socket left the room, so membership scans no longer see it —
192+
// the retry queue must drive the cleanup to completion.
193+
await sweep.runOnce()
194+
sweep.stop()
195+
196+
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
197+
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
198+
})
199+
200+
it('defers cleanup when removal fails with expired socket mappings', async () => {
201+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
202+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
203+
// Mapping keys already expired (lookup resolves null) AND the removal fails
204+
// (the Redis manager swallows the transport error into null) — the failed
205+
// removal must still defer instead of reading as success.
206+
manager.removeUserFromRoom.mockResolvedValueOnce(null)
207+
mockResolveRole.mockResolvedValue(null)
208+
209+
const sweep = startAccessRevalidationSweep(manager)
210+
await sweep.runOnce()
211+
212+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
213+
185214
await sweep.runOnce()
186215
sweep.stop()
187216

@@ -204,8 +233,7 @@ describe('access-revalidation sweep', () => {
204233
expect(socket.leave).toHaveBeenCalledWith('wf-1')
205234
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
206235

207-
// Next pass: the socket left the room, and the removal now succeeds.
208-
socket.rooms = new Set(['sock-1'])
236+
// Next pass: the removal now succeeds and the cleanup completes.
209237
await sweep.runOnce()
210238
sweep.stop()
211239

@@ -240,8 +268,9 @@ describe('access-revalidation sweep', () => {
240268
await sweep.runOnce()
241269
expect(socket.leave).toHaveBeenCalledWith('wf-1')
242270

243-
// Access restored and the socket re-joined (still in the room in this
244-
// fake): the retry must NOT remove the fresh presence entry.
271+
// Access restored and the socket re-joined the same room: the retry must
272+
// NOT remove the fresh presence entry that re-join created.
273+
socket.rooms.add('wf-1')
245274
mockResolveRole.mockResolvedValue('read')
246275
await sweep.runOnce()
247276
sweep.stop()

apps/realtime/src/access-revalidation.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,20 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
9595
return
9696
}
9797

98+
// Synchronous re-join guard with no awaits before the removal: if the
99+
// socket legitimately re-joined this room after the eviction (access
100+
// restored), that join re-added its presence — removal would erase it.
101+
if (io.sockets.sockets.get(socketId)?.rooms.has(workflowId)) {
102+
pendingCleanups.delete(key)
103+
return
104+
}
105+
98106
const removed = await roomManager.removeUserFromRoom(socketId, workflowId)
99-
if (removed === null && currentWorkflowId !== null) {
100-
// The Redis manager swallows transport errors into null — a live
101-
// mapping with no reported removal means the removal did not happen.
107+
if (removed === null) {
108+
// The sweep always passes the target room, and both managers report a
109+
// performed removal by returning it — the Redis manager swallows
110+
// transport errors into null, so null means the removal did not happen
111+
// (even when the socket's mapping keys have already expired).
102112
throw new Error('room-state removal not confirmed')
103113
}
104114

@@ -114,15 +124,7 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
114124
}
115125

116126
async function retryPendingCleanups(): Promise<void> {
117-
for (const [key, { socketId, workflowId }] of pendingCleanups) {
118-
const liveSocket = io.sockets.sockets.get(socketId)
119-
if (liveSocket?.rooms.has(workflowId)) {
120-
// The socket re-joined legitimately after the eviction (access was
121-
// restored); that join re-added its presence entry, so there is
122-
// nothing stale left to clean and removal would erase live presence.
123-
pendingCleanups.delete(key)
124-
continue
125-
}
127+
for (const [, { socketId, workflowId }] of pendingCleanups) {
126128
await cleanupEvictedSocket(socketId, workflowId)
127129
}
128130
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,23 @@ vi.mock('@sim/platform-authz/workflow', () => ({
2323
authorizeWorkflowByWorkspacePermission: mockAuthorize,
2424
}))
2525

26+
vi.mock('@sim/db', () => ({
27+
db: {
28+
select: vi.fn(() => ({
29+
from: vi.fn(() => ({
30+
where: vi.fn(() => ({
31+
limit: vi.fn(async () => [{ workspaceId: 'ws-1', name: 'Test Workflow' }]),
32+
})),
33+
})),
34+
})),
35+
},
36+
}))
37+
2638
import {
2739
checkRolePermission,
2840
checkWorkflowOperationPermission,
2941
resolveCurrentWorkflowRole,
42+
verifyWorkflowAccess,
3043
} from '@/middleware/permissions'
3144

3245
describe('checkRolePermission', () => {
@@ -481,3 +494,28 @@ describe('resolveCurrentWorkflowRole single-flight', () => {
481494
}
482495
})
483496
})
497+
498+
describe('verifyWorkflowAccess role-cache refresh', () => {
499+
beforeEach(() => {
500+
vi.clearAllMocks()
501+
})
502+
503+
it('records the fresh decision so a stale cached revocation does not block a re-granted join', async () => {
504+
const userId = 'vw-user-1'
505+
const workflowId = 'vw-wf-1'
506+
507+
// A sweep-style resolution records the revocation.
508+
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
509+
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBeNull()
510+
511+
// Access is restored and a fresh join-time verify succeeds.
512+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
513+
const access = await verifyWorkflowAccess(userId, workflowId)
514+
expect(access.hasAccess).toBe(true)
515+
516+
// The pre-join gate's warm read now sees the fresh role, not the stale
517+
// cached null recorded before the re-grant.
518+
mockAuthorize.mockRejectedValue(new Error('must not re-query'))
519+
expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write')
520+
})
521+
})

apps/realtime/src/middleware/permissions.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ function purgeExpiredRoles(now: number): void {
129129
}
130130
}
131131

132+
/**
133+
* Records a freshly-read authoritative decision into the role cache. Every
134+
* successful DB read of a user's workspace role goes through this — including
135+
* the join-time {@link verifyWorkflowAccess} — so a stale cached revocation
136+
* never outlives a newer authoritative read (e.g. a re-granted user re-joining
137+
* within the TTL of the sweep's recorded `null`).
138+
*/
139+
function recordRoleDecision(key: string, role: string | null): void {
140+
const now = Date.now()
141+
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
142+
purgeExpiredRoles(now)
143+
}
144+
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
145+
}
146+
132147
async function resolveRoleUncached(
133148
key: string,
134149
userId: string,
@@ -142,11 +157,7 @@ async function resolveRoleUncached(
142157
action: 'read',
143158
})
144159
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
145-
const now = Date.now()
146-
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
147-
purgeExpiredRoles(now)
148-
}
149-
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
160+
recordRoleDecision(key, role)
150161
return role
151162
} catch (error) {
152163
logger.warn(
@@ -226,6 +237,11 @@ export async function checkWorkflowOperationPermission(
226237
* Returns `hasAccess: false` only for genuine denials (workflow missing/archived
227238
* or no workspace permission). Transient failures (DB errors) are rethrown so the
228239
* caller can report them as retryable instead of a permanent access denial.
240+
*
241+
* The fresh authorization decision is recorded into the role cache, so the
242+
* pre-join re-check and the eviction sweep see it immediately — in particular,
243+
* a user whose access was revoked and then restored is not blocked by the
244+
* sweep's stale cached revocation for the remainder of its TTL.
229245
*/
230246
export async function verifyWorkflowAccess(
231247
userId: string,
@@ -253,6 +269,11 @@ export async function verifyWorkflowAccess(
253269
action: 'read',
254270
})
255271

272+
recordRoleDecision(
273+
`${userId}:${workflowId}`,
274+
authorization.allowed ? (authorization.workspacePermission ?? null) : null
275+
)
276+
256277
if (!authorization.allowed || !authorization.workspacePermission) {
257278
logger.warn(
258279
`User ${userId} is not permitted to access workflow ${workflowId}: ${authorization.message}`

0 commit comments

Comments
 (0)