Skip to content

Commit 5996aa2

Browse files
committed
fix(realtime): re-check workspace role on mutating socket events
1 parent 4a7c2ef commit 5996aa2

6 files changed

Lines changed: 223 additions & 10 deletions

File tree

apps/realtime/src/handlers/operations.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
1515
import { ZodError } from 'zod'
1616
import { persistWorkflowOperation } from '@/database/operations'
1717
import type { AuthenticatedSocket } from '@/middleware/auth'
18-
import { checkRolePermission } from '@/middleware/permissions'
18+
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
1919
import type { IRoomManager, UserSession } from '@/rooms'
2020

2121
const logger = createLogger('OperationsHandlers')
@@ -125,11 +125,17 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager
125125

126126
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
127127

128-
// Check permissions using cached role (no DB query)
129-
const permissionCheck = checkRolePermission(userPresence.role, operation)
128+
// Re-validate the workspace role against the DB (cached per pod for a short
129+
// window) so revoked or downgraded collaborators lose write access live.
130+
const permissionCheck = await checkWorkflowOperationPermission(
131+
session.userId,
132+
workflowId,
133+
operation,
134+
userPresence.role
135+
)
130136
if (!permissionCheck.allowed) {
131137
logger.warn(
132-
`User ${session.userId} (role: ${userPresence.role}) forbidden from ${operation} on ${target}`
138+
`User ${session.userId} (role: ${permissionCheck.role ?? 'none'}) forbidden from ${operation} on ${target}`
133139
)
134140
emitOperationError({
135141
type: 'INSUFFICIENT_PERMISSIONS',

apps/realtime/src/handlers/subblocks.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
77
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
88
import { and, eq } from 'drizzle-orm'
99
import type { AuthenticatedSocket } from '@/middleware/auth'
10-
import { checkRolePermission } from '@/middleware/permissions'
10+
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
1111
import type { IRoomManager } from '@/rooms'
1212

1313
const logger = createLogger('SubblocksHandlers')
@@ -136,7 +136,12 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
136136
return
137137
}
138138

139-
const permissionCheck = checkRolePermission(userPresence.role, SUBBLOCK_OPERATIONS.UPDATE)
139+
const permissionCheck = await checkWorkflowOperationPermission(
140+
session.userId,
141+
workflowId,
142+
SUBBLOCK_OPERATIONS.UPDATE,
143+
userPresence.role
144+
)
140145
if (!permissionCheck.allowed) {
141146
socket.emit('operation-forbidden', {
142147
type: 'INSUFFICIENT_PERMISSIONS',

apps/realtime/src/handlers/variables.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
77
import { eq } from 'drizzle-orm'
88
import type { AuthenticatedSocket } from '@/middleware/auth'
9-
import { checkRolePermission } from '@/middleware/permissions'
9+
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
1010
import type { IRoomManager } from '@/rooms'
1111

1212
const logger = createLogger('VariablesHandlers')
@@ -124,7 +124,12 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
124124
return
125125
}
126126

127-
const permissionCheck = checkRolePermission(userPresence.role, VARIABLE_OPERATIONS.UPDATE)
127+
const permissionCheck = await checkWorkflowOperationPermission(
128+
session.userId,
129+
workflowId,
130+
VARIABLE_OPERATIONS.UPDATE,
131+
userPresence.role
132+
)
128133
if (!permissionCheck.allowed) {
129134
socket.emit('operation-forbidden', {
130135
type: 'INSUFFICIENT_PERMISSIONS',

apps/realtime/src/index.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ vi.mock('@/middleware/permissions', () => ({
7373
checkRolePermission: vi.fn().mockReturnValue({
7474
allowed: true,
7575
}),
76+
checkWorkflowOperationPermission: vi.fn().mockResolvedValue({
77+
allowed: true,
78+
role: 'admin',
79+
}),
7680
}))
7781

7882
vi.mock('@/database/operations', () => ({

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

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ import {
1313
ROLE_ALLOWED_OPERATIONS,
1414
SOCKET_OPERATIONS,
1515
} from '@sim/testing'
16-
import { describe, expect, it } from 'vitest'
17-
import { checkRolePermission } from '@/middleware/permissions'
16+
import { beforeEach, describe, expect, it, vi } from 'vitest'
17+
18+
const { mockAuthorize } = vi.hoisted(() => ({
19+
mockAuthorize: vi.fn(),
20+
}))
21+
22+
vi.mock('@sim/workflow-authz', () => ({
23+
authorizeWorkflowByWorkspacePermission: mockAuthorize,
24+
}))
25+
26+
import { checkRolePermission, checkWorkflowOperationPermission } from '@/middleware/permissions'
1827

1928
describe('checkRolePermission', () => {
2029
describe('admin role', () => {
@@ -279,3 +288,89 @@ describe('checkRolePermission', () => {
279288
})
280289
})
281290
})
291+
292+
describe('checkWorkflowOperationPermission', () => {
293+
const userId = 'user-1'
294+
let workflowCounter = 0
295+
let workflowId: string
296+
297+
beforeEach(() => {
298+
vi.clearAllMocks()
299+
// Unique workflowId per test so the module-level role cache never leaks across tests
300+
workflowCounter += 1
301+
workflowId = `wf-${workflowCounter}`
302+
})
303+
304+
it('allows a write operation when the user still has write access', async () => {
305+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
306+
307+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
308+
309+
expect(result.allowed).toBe(true)
310+
expect(result.role).toBe('write')
311+
})
312+
313+
it('denies all writes once workspace access has been revoked', async () => {
314+
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
315+
316+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
317+
318+
expect(result.allowed).toBe(false)
319+
expect(result.role).toBeNull()
320+
expect(result.reason).toMatch(/revoked/i)
321+
})
322+
323+
it('denies writes after a downgrade to read but still allows position updates', async () => {
324+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
325+
326+
const denied = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
327+
expect(denied.allowed).toBe(false)
328+
expect(denied.role).toBe('read')
329+
330+
const allowed = await checkWorkflowOperationPermission(
331+
userId,
332+
workflowId,
333+
'update-position',
334+
'write'
335+
)
336+
expect(allowed.allowed).toBe(true)
337+
expect(allowed.role).toBe('read')
338+
})
339+
340+
it('caches the role within the TTL to avoid a DB read on every operation', async () => {
341+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
342+
343+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
344+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
345+
346+
expect(mockAuthorize).toHaveBeenCalledTimes(1)
347+
})
348+
349+
it('re-reads the role after the cache TTL expires', async () => {
350+
vi.useFakeTimers()
351+
try {
352+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
353+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
354+
355+
// Downgraded to read after the first check
356+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
357+
vi.advanceTimersByTime(31_000)
358+
359+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
360+
expect(mockAuthorize).toHaveBeenCalledTimes(2)
361+
expect(result.allowed).toBe(false)
362+
expect(result.role).toBe('read')
363+
} finally {
364+
vi.useRealTimers()
365+
}
366+
})
367+
368+
it('falls back to the last known role on a transient DB error', async () => {
369+
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
370+
371+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
372+
373+
expect(result.allowed).toBe(true)
374+
expect(result.role).toBe('write')
375+
})
376+
})

apps/realtime/src/middleware/permissions.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,104 @@ export function checkRolePermission(
8383
return { allowed: true }
8484
}
8585

86+
/**
87+
* TTL for the per-pod role cache backing live re-validation of mutating operations.
88+
* Bounds how long a revoked or downgraded collaborator can retain write access on an
89+
* already-connected socket.
90+
*/
91+
const ROLE_REVALIDATION_TTL_MS = 30_000
92+
93+
/** Soft cap on cached entries before an opportunistic purge of expired ones runs. */
94+
const MAX_ROLE_CACHE_ENTRIES = 5_000
95+
96+
interface CachedRole {
97+
/** Authoritative workspace role, or `null` when the user has no access. */
98+
role: string | null
99+
expiresAt: number
100+
}
101+
102+
/**
103+
* Per-pod cache of authoritative workspace roles, keyed by `${userId}:${workflowId}`.
104+
*
105+
* Socket connections are sticky to a single pod, so a socket's mutating operations are
106+
* always gated by the same pod's cache. We rely on TTL expiry (not cross-pod
107+
* invalidation) to bound stale authorization to {@link ROLE_REVALIDATION_TTL_MS}, which
108+
* keeps this correct under a multi-pod deployment without any shared state.
109+
*/
110+
const roleCache = new Map<string, CachedRole>()
111+
112+
function purgeExpiredRoles(now: number): void {
113+
for (const [key, entry] of roleCache) {
114+
if (entry.expiresAt <= now) {
115+
roleCache.delete(key)
116+
}
117+
}
118+
}
119+
120+
/**
121+
* Resolves a user's current workspace role for a workflow, re-reading the `permissions`
122+
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
123+
*
124+
* 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.
127+
*/
128+
export async function resolveCurrentWorkflowRole(
129+
userId: string,
130+
workflowId: string,
131+
fallbackRole: string
132+
): Promise<string | null> {
133+
const now = Date.now()
134+
const key = `${userId}:${workflowId}`
135+
const cached = roleCache.get(key)
136+
if (cached && cached.expiresAt > now) {
137+
return cached.role
138+
}
139+
140+
try {
141+
const authorization = await authorizeWorkflowByWorkspacePermission({
142+
workflowId,
143+
userId,
144+
action: 'read',
145+
})
146+
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
147+
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
148+
purgeExpiredRoles(now)
149+
}
150+
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
151+
return role
152+
} catch (error) {
153+
logger.warn(
154+
`Failed to re-validate role for user ${userId} on workflow ${workflowId}; using last known role`,
155+
error
156+
)
157+
return fallbackRole
158+
}
159+
}
160+
161+
/**
162+
* Live permission gate for mutating socket operations. Re-validates the user's workspace
163+
* role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that
164+
* revoked or downgraded collaborators lose write access on an open connection without
165+
* needing to rejoin the workflow.
166+
*/
167+
export async function checkWorkflowOperationPermission(
168+
userId: string,
169+
workflowId: string,
170+
operation: string,
171+
fallbackRole: string
172+
): Promise<{ allowed: boolean; reason?: string; role: string | null }> {
173+
const role = await resolveCurrentWorkflowRole(userId, workflowId, fallbackRole)
174+
if (!role) {
175+
return {
176+
allowed: false,
177+
reason: 'Access to this workflow has been revoked',
178+
role: null,
179+
}
180+
}
181+
return { ...checkRolePermission(role, operation), role }
182+
}
183+
86184
/**
87185
* Verifies a user's access to a workflow via workspace permissions.
88186
*

0 commit comments

Comments
 (0)