Skip to content

Commit e9ab6ec

Browse files
committed
fix(realtime): evict revoked collaborators from live workflow rooms via periodic read-access re-validation
1 parent d48722a commit e9ab6ec

8 files changed

Lines changed: 416 additions & 4 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Tests for the periodic read-access re-validation sweep. The security contract:
5+
* a socket is evicted only when its role resolves to `null` (a confirmed
6+
* revocation), and a transient failure never evicts a still-authorized socket.
7+
*/
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockResolveRole } = vi.hoisted(() => ({
11+
mockResolveRole: vi.fn(),
12+
}))
13+
14+
vi.mock('@/middleware/permissions', () => ({
15+
resolveCurrentWorkflowRole: mockResolveRole,
16+
ROLE_REVALIDATION_TTL_MS: 30_000,
17+
}))
18+
19+
import { startAccessRevalidationSweep } from '@/access-revalidation'
20+
import type { IRoomManager, UserPresence } from '@/rooms'
21+
22+
interface FakeSocket {
23+
id: string
24+
userId?: string
25+
rooms: Set<string>
26+
emit: ReturnType<typeof vi.fn>
27+
leave: ReturnType<typeof vi.fn>
28+
}
29+
30+
function makeSocket(id: string, userId: string | undefined, workflowId?: string): FakeSocket {
31+
const rooms = new Set<string>([id])
32+
if (workflowId) rooms.add(workflowId)
33+
return { id, userId, rooms, emit: vi.fn(), leave: vi.fn() }
34+
}
35+
36+
function makeManager(sockets: FakeSocket[], presence: Partial<UserPresence>[] = []) {
37+
const socketMap = new Map(sockets.map((s) => [s.id, s]))
38+
const manager = {
39+
io: { sockets: { sockets: socketMap } },
40+
isReady: () => true,
41+
getWorkflowUsers: vi.fn().mockResolvedValue(presence),
42+
removeUserFromRoom: vi.fn().mockResolvedValue(null),
43+
broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined),
44+
}
45+
return manager as unknown as IRoomManager & {
46+
getWorkflowUsers: ReturnType<typeof vi.fn>
47+
removeUserFromRoom: ReturnType<typeof vi.fn>
48+
broadcastPresenceUpdate: ReturnType<typeof vi.fn>
49+
}
50+
}
51+
52+
describe('access-revalidation sweep', () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks()
55+
})
56+
57+
it('evicts a socket whose role has been revoked', async () => {
58+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
59+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
60+
mockResolveRole.mockResolvedValue(null)
61+
62+
const sweep = startAccessRevalidationSweep(manager)
63+
await sweep.runOnce()
64+
sweep.stop()
65+
66+
expect(socket.emit).toHaveBeenCalledWith(
67+
'access-revoked',
68+
expect.objectContaining({ workflowId: 'wf-1' })
69+
)
70+
expect(socket.leave).toHaveBeenCalledWith('wf-1')
71+
expect(manager.removeUserFromRoom).toHaveBeenCalledWith('sock-1', 'wf-1')
72+
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
73+
})
74+
75+
it('keeps a socket whose access is still valid', async () => {
76+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
77+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'write' }])
78+
mockResolveRole.mockResolvedValue('write')
79+
80+
const sweep = startAccessRevalidationSweep(manager)
81+
await sweep.runOnce()
82+
sweep.stop()
83+
84+
expect(socket.emit).not.toHaveBeenCalled()
85+
expect(socket.leave).not.toHaveBeenCalled()
86+
expect(manager.removeUserFromRoom).not.toHaveBeenCalled()
87+
})
88+
89+
it('does not evict a downgraded-but-still-authorized socket', async () => {
90+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
91+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'admin' }])
92+
// Downgraded admin -> read still resolves to a non-null role: keep the reader.
93+
mockResolveRole.mockResolvedValue('read')
94+
95+
const sweep = startAccessRevalidationSweep(manager)
96+
await sweep.runOnce()
97+
sweep.stop()
98+
99+
expect(socket.emit).not.toHaveBeenCalled()
100+
expect(socket.leave).not.toHaveBeenCalled()
101+
})
102+
103+
it('never evicts when re-validation throws (transient failure)', async () => {
104+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
105+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
106+
mockResolveRole.mockRejectedValue(new Error('db unreachable'))
107+
108+
const sweep = startAccessRevalidationSweep(manager)
109+
await sweep.runOnce()
110+
sweep.stop()
111+
112+
expect(socket.emit).not.toHaveBeenCalled()
113+
expect(socket.leave).not.toHaveBeenCalled()
114+
expect(manager.removeUserFromRoom).not.toHaveBeenCalled()
115+
})
116+
117+
it('passes the join-time presence role as the fallback', async () => {
118+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
119+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'admin' }])
120+
mockResolveRole.mockResolvedValue('admin')
121+
122+
const sweep = startAccessRevalidationSweep(manager)
123+
await sweep.runOnce()
124+
sweep.stop()
125+
126+
expect(mockResolveRole).toHaveBeenCalledWith('user-1', 'wf-1', 'admin')
127+
})
128+
129+
it('evicts only the revoked socket, not co-members of the room', async () => {
130+
const revoked = makeSocket('sock-1', 'user-1', 'wf-1')
131+
const kept = makeSocket('sock-2', 'user-2', 'wf-1')
132+
const manager = makeManager(
133+
[revoked, kept],
134+
[
135+
{ socketId: 'sock-1', role: 'read' },
136+
{ socketId: 'sock-2', role: 'write' },
137+
]
138+
)
139+
mockResolveRole.mockImplementation(async (userId: string) =>
140+
userId === 'user-1' ? null : 'write'
141+
)
142+
143+
const sweep = startAccessRevalidationSweep(manager)
144+
await sweep.runOnce()
145+
sweep.stop()
146+
147+
expect(revoked.leave).toHaveBeenCalledWith('wf-1')
148+
expect(kept.leave).not.toHaveBeenCalled()
149+
expect(kept.emit).not.toHaveBeenCalled()
150+
})
151+
152+
it('skips unauthenticated sockets and sockets not in a workflow room', async () => {
153+
const noUser = makeSocket('sock-1', undefined, 'wf-1')
154+
const noRoom = makeSocket('sock-2', 'user-2')
155+
const manager = makeManager([noUser, noRoom])
156+
157+
const sweep = startAccessRevalidationSweep(manager)
158+
await sweep.runOnce()
159+
sweep.stop()
160+
161+
expect(mockResolveRole).not.toHaveBeenCalled()
162+
expect(noUser.leave).not.toHaveBeenCalled()
163+
expect(noRoom.leave).not.toHaveBeenCalled()
164+
})
165+
166+
it('still evaluates access when presence lookup fails (falls back safely)', async () => {
167+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
168+
const manager = makeManager([socket])
169+
manager.getWorkflowUsers.mockRejectedValue(new Error('redis down'))
170+
mockResolveRole.mockResolvedValue(null)
171+
172+
const sweep = startAccessRevalidationSweep(manager)
173+
await sweep.runOnce()
174+
sweep.stop()
175+
176+
// Presence unavailable → default fallback role, but the DB check still runs
177+
// and a confirmed revocation still evicts.
178+
expect(mockResolveRole).toHaveBeenCalledWith('user-1', 'wf-1', 'read')
179+
expect(socket.leave).toHaveBeenCalledWith('wf-1')
180+
})
181+
})
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events'
3+
import type { AuthenticatedSocket } from '@/middleware/auth'
4+
import { ROLE_REVALIDATION_TTL_MS, resolveCurrentWorkflowRole } from '@/middleware/permissions'
5+
import type { IRoomManager } from '@/rooms'
6+
7+
const logger = createLogger('AccessRevalidation')
8+
9+
/**
10+
* How often each pod re-validates live read access for its connected sockets.
11+
*
12+
* Coupled to {@link ROLE_REVALIDATION_TTL_MS} — the same per-pod role cache and
13+
* TTL that already bound *write* revocation — so a collaborator whose workspace
14+
* permission is removed loses live *reads* within a comparable window instead of
15+
* retaining them until they disconnect. Detection latency is one cache TTL plus
16+
* up to one sweep interval (the sweep keeps seeing the cached non-null role
17+
* until it expires), so ~30s typical and bounded well under a minute worst case.
18+
*/
19+
export const ACCESS_REVALIDATION_SWEEP_INTERVAL_MS = ROLE_REVALIDATION_TTL_MS
20+
21+
/**
22+
* Non-null fallback used only when a socket has no presence entry to read its
23+
* join-time role from. It is consumed by {@link resolveCurrentWorkflowRole} only
24+
* on a transient DB failure with a cold cache, where returning a non-null role
25+
* (never eviction) is the safe outcome during an outage.
26+
*/
27+
const FALLBACK_ROLE = 'read'
28+
29+
export interface AccessRevalidationSweep {
30+
/** Stop the periodic sweep (clears the interval). */
31+
stop: () => void
32+
/** Run a single sweep pass. Exposed for deterministic testing. */
33+
runOnce: () => Promise<void>
34+
}
35+
36+
/**
37+
* Groups this pod's local sockets by the workflow room each has joined.
38+
*
39+
* The workflow room is derived from the socket's own `rooms` set (pod-local, no
40+
* Redis round-trips): a socket joins exactly one workflow room, so its rooms are
41+
* `{ ownSocketId, workflowId }`. Only local sockets are evaluated — sockets are
42+
* sticky to a pod, so every socket is swept by exactly one pod using that pod's
43+
* warm role cache (mirroring the per-pod reasoning of the write-path cache).
44+
*/
45+
function collectLocalMemberships(io: IRoomManager['io']): Map<string, AuthenticatedSocket[]> {
46+
const byWorkflow = new Map<string, AuthenticatedSocket[]>()
47+
for (const socket of io.sockets.sockets.values()) {
48+
const authed = socket as AuthenticatedSocket
49+
if (!authed.userId) continue
50+
for (const room of socket.rooms) {
51+
if (room === socket.id) continue
52+
const existing = byWorkflow.get(room)
53+
if (existing) existing.push(authed)
54+
else byWorkflow.set(room, [authed])
55+
}
56+
}
57+
return byWorkflow
58+
}
59+
60+
/**
61+
* Starts a per-pod loop that re-validates every connected socket's workspace
62+
* role and evicts sockets whose access has been revoked, closing the read-side
63+
* gap left by the join-only access check.
64+
*
65+
* Blip-safety: eviction fires *only* when {@link resolveCurrentWorkflowRole}
66+
* returns `null`, which happens solely for a successful DB "no access" result or
67+
* a previously-recorded revocation reused across a failure. A transient DB error
68+
* against a still-authorized (or freshly-joined) user resolves to the last-known
69+
* or fallback role, so a database blip never evicts anyone.
70+
*/
71+
export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessRevalidationSweep {
72+
const io = roomManager.io
73+
let running = false
74+
75+
async function revokeSocket(socket: AuthenticatedSocket, workflowId: string): Promise<void> {
76+
// Security-critical, pod-local, and synchronous: stop this socket receiving
77+
// room broadcasts immediately, before any async bookkeeping that could fail.
78+
const payload: AccessRevokedBroadcast = {
79+
workflowId,
80+
message: 'Your access to this workflow has been revoked',
81+
timestamp: Date.now(),
82+
}
83+
socket.emit('access-revoked', payload)
84+
socket.leave(workflowId)
85+
86+
logger.info(
87+
`Revoked live access for user ${socket.userId} on workflow ${workflowId} (socket ${socket.id})`
88+
)
89+
90+
// Best-effort room-state cleanup; failure here does not restore access.
91+
await roomManager.removeUserFromRoom(socket.id, workflowId)
92+
await roomManager.broadcastPresenceUpdate(workflowId)
93+
}
94+
95+
async function runOnce(): Promise<void> {
96+
const memberships = collectLocalMemberships(io)
97+
98+
for (const [workflowId, sockets] of memberships) {
99+
// One presence read per active workflow supplies each socket's join-time
100+
// role as the transient-failure fallback (a fallback never evicts).
101+
const fallbackBySocket = new Map<string, string>()
102+
try {
103+
const presence = await roomManager.getWorkflowUsers(workflowId)
104+
for (const entry of presence) {
105+
fallbackBySocket.set(entry.socketId, entry.role)
106+
}
107+
} catch (error) {
108+
logger.warn(`Failed to load presence for ${workflowId}; using default fallback role`, error)
109+
}
110+
111+
for (const socket of sockets) {
112+
const userId = socket.userId
113+
if (!userId) continue
114+
115+
try {
116+
const fallbackRole = fallbackBySocket.get(socket.id) ?? FALLBACK_ROLE
117+
const role = await resolveCurrentWorkflowRole(userId, workflowId, fallbackRole)
118+
if (role === null) {
119+
await revokeSocket(socket, workflowId)
120+
}
121+
} catch (error) {
122+
// Never evict on an unexpected error — only a definitive `null` role
123+
// evicts, so a failure here leaves the socket's access intact.
124+
logger.warn(
125+
`Access re-validation failed for user ${userId} on workflow ${workflowId}; leaving membership intact`,
126+
error
127+
)
128+
}
129+
}
130+
}
131+
}
132+
133+
const timer = setInterval(() => {
134+
if (running) {
135+
logger.warn('Skipping access re-validation sweep; previous sweep still running')
136+
return
137+
}
138+
running = true
139+
runOnce()
140+
.catch((error) => logger.error('Access re-validation sweep failed', error))
141+
.finally(() => {
142+
running = false
143+
})
144+
}, ACCESS_REVALIDATION_SWEEP_INTERVAL_MS)
145+
146+
// Do not keep the process alive solely for this timer.
147+
timer.unref?.()
148+
149+
logger.info(
150+
`Access re-validation sweep started (every ${ACCESS_REVALIDATION_SWEEP_INTERVAL_MS}ms)`
151+
)
152+
153+
return {
154+
stop: () => clearInterval(timer),
155+
runOnce,
156+
}
157+
}

apps/realtime/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createServer } from 'http'
22
import { createLogger } from '@sim/logger'
33
import type { Server as SocketIOServer } from 'socket.io'
4+
import { startAccessRevalidationSweep } from '@/access-revalidation'
45
import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket'
56
import { assertSchemaCompatibility } from '@/database/preflight'
67
import { env } from '@/env'
@@ -94,6 +95,10 @@ async function main() {
9495
setupAllHandlers(socket, roomManager)
9596
})
9697

98+
// Bound read-access staleness: periodically re-validate connected sockets and
99+
// evict any whose workspace permission has been revoked, matching the write path.
100+
const accessRevalidation = startAccessRevalidationSweep(roomManager)
101+
97102
await assertSchemaCompatibility()
98103

99104
httpServer.listen(PORT, '0.0.0.0', () => {
@@ -104,6 +109,8 @@ async function main() {
104109
const shutdown = async () => {
105110
logger.info('Shutting down Socket.IO server...')
106111

112+
accessRevalidation.stop()
113+
107114
try {
108115
await roomManager.shutdown()
109116
logger.info('RoomManager shutdown complete')

apps/realtime/src/middleware/permissions.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ export function checkRolePermission(
8585
}
8686

8787
/**
88-
* TTL for the per-pod role cache backing live re-validation of mutating operations.
89-
* Bounds how long a revoked or downgraded collaborator can retain write access on an
90-
* already-connected socket.
88+
* TTL for the per-pod role cache backing live re-validation. It gates both the
89+
* mutating-operation checks ({@link checkWorkflowOperationPermission}) and the
90+
* periodic read-access sweep (`access-revalidation.ts`), so a revoked or
91+
* downgraded collaborator loses write access — and live reads — on an
92+
* already-connected socket within a bounded window rather than until disconnect.
9193
*/
92-
const ROLE_REVALIDATION_TTL_MS = 30_000
94+
export const ROLE_REVALIDATION_TTL_MS = 30_000
9395

9496
/** Soft cap on cached entries before an opportunistic purge of expired ones runs. */
9597
const MAX_ROLE_CACHE_ENTRIES = 5_000

0 commit comments

Comments
 (0)