Skip to content

Commit e5d2f7d

Browse files
authored
fix(realtime): stop read-only members persisting block positions (#6174)
* fix(realtime): stop read-only members persisting block positions The socket operation ACL granted the `read` role block.updatePosition and blocks.batchUpdatePositions on the premise that they were ephemeral cursor sync. They are not: both are followed by persistWorkflowOperation, which UPDATEs workflow_blocks.positionX/positionY and bumps workflow.updatedAt. A member holding only `read` on a workspace could therefore permanently rewrite the coordinates of every block of every workflow in it — a write across the read/write boundary. Live cursors ride their own `cursor-update` event, and the smooth-drag broadcast is the UNCOMMITTED position path, which returns before persisting and never consults the role table — so the read role needs no grant at all. * test(realtime): assert the role ACL against production, not a fixture The shared ROLE_ALLOWED_OPERATIONS fixture still listed the two position operations for the read role, and three tests compared that fixture against itself — so they certified whatever it said, including the grants this PR removes. They now assert checkRolePermission over the protocol's complete operation list (the fixture's copy omits subblock/variable/admin-only ops), plus one test that pins the fixture to the production ACL so the two cannot drift apart again.
1 parent ccc2ec9 commit e5d2f7d

4 files changed

Lines changed: 269 additions & 45 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+
* End-to-end guard for the socket operation ACL: the security boundary is not the
5+
* role table on its own but whether a role reaches `persistWorkflowOperation`.
6+
* These tests drive the real handler with the real permission middleware (only the
7+
* database and the workspace authorizer are mocked) and assert on the persist call,
8+
* because that is what durably rewrites `workflow_blocks`.
9+
*/
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
import type { IRoomManager } from '@/rooms'
12+
13+
const { mockAuthorizeWorkflow, mockPersist, mockAssertMutable } = vi.hoisted(() => ({
14+
mockAuthorizeWorkflow: vi.fn(),
15+
mockPersist: vi.fn(),
16+
mockAssertMutable: vi.fn(),
17+
}))
18+
19+
vi.mock('@sim/platform-authz/workflow', () => ({
20+
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow,
21+
assertWorkflowMutable: mockAssertMutable,
22+
WorkflowLockedError: class WorkflowLockedError extends Error {},
23+
}))
24+
25+
vi.mock('@/database/operations', () => ({
26+
persistWorkflowOperation: mockPersist,
27+
}))
28+
29+
import { setupOperationsHandlers } from '@/handlers/operations'
30+
31+
const WORKFLOW_ID = 'wf-acl'
32+
const BLOCK_ID = 'block-1'
33+
34+
type Handler = (payload: unknown) => Promise<void> | void
35+
36+
function createSocket(id: string) {
37+
const handlers: Record<string, Handler> = {}
38+
const toEmit = vi.fn()
39+
const socket = {
40+
id,
41+
userId: `user-${id}`,
42+
userName: 'Test User',
43+
on: vi.fn((event: string, handler: Handler) => {
44+
handlers[event] = handler
45+
}),
46+
emit: vi.fn(),
47+
to: vi.fn().mockReturnValue({ emit: toEmit }),
48+
}
49+
return { socket, handlers, toEmit }
50+
}
51+
52+
function createRoomManager(socketId: string, role: string): IRoomManager {
53+
return {
54+
isReady: () => true,
55+
getRoomForSocket: vi.fn().mockResolvedValue({ type: 'workflow', id: WORKFLOW_ID }),
56+
getUserSession: vi
57+
.fn()
58+
.mockResolvedValue({ userId: `user-${socketId}`, userName: 'Test User' }),
59+
hasRoom: vi.fn().mockResolvedValue(true),
60+
getRoomUsers: vi.fn().mockResolvedValue([{ socketId, userId: `user-${socketId}`, role }]),
61+
updateUserActivity: vi.fn().mockResolvedValue(undefined),
62+
updateRoomLastModified: vi.fn().mockResolvedValue(undefined),
63+
} as unknown as IRoomManager
64+
}
65+
66+
/** A committed single-block move — the form that writes `workflow_blocks`. */
67+
function committedPositionUpdate() {
68+
return {
69+
operationId: 'op-1',
70+
operation: 'update-position',
71+
target: 'block',
72+
timestamp: Date.now(),
73+
payload: { id: BLOCK_ID, position: { x: 999_999, y: 999_999 }, commit: true },
74+
}
75+
}
76+
77+
/** The batch form, which persists with no `commit` flag at all. */
78+
function batchPositionUpdate() {
79+
return {
80+
operationId: 'op-2',
81+
operation: 'batch-update-positions',
82+
target: 'blocks',
83+
timestamp: Date.now(),
84+
payload: { updates: [{ id: BLOCK_ID, position: { x: 1, y: 2 } }] },
85+
}
86+
}
87+
88+
function setup(id: string, role: string) {
89+
const { socket, handlers, toEmit } = createSocket(id)
90+
setupOperationsHandlers(
91+
socket as unknown as Parameters<typeof setupOperationsHandlers>[0],
92+
createRoomManager(id, role)
93+
)
94+
return { socket, handlers, toEmit }
95+
}
96+
97+
describe('workflow operation ACL', () => {
98+
beforeEach(() => {
99+
vi.clearAllMocks()
100+
mockAssertMutable.mockResolvedValue(undefined)
101+
mockPersist.mockResolvedValue(undefined)
102+
})
103+
104+
describe('read-only member', () => {
105+
beforeEach(() => {
106+
mockAuthorizeWorkflow.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
107+
})
108+
109+
it('cannot persist a committed block position', async () => {
110+
// Unique socket id per test: the permission cache is module-global and keyed
111+
// by (user, workflow), so sharing a user across roles would hit a warm entry.
112+
const { socket, handlers } = setup('sock-read-1', 'read')
113+
114+
await handlers['workflow-operation'](committedPositionUpdate())
115+
116+
expect(mockPersist).not.toHaveBeenCalled()
117+
expect(socket.emit).toHaveBeenCalledWith(
118+
'operation-forbidden',
119+
expect.objectContaining({ type: 'INSUFFICIENT_PERMISSIONS' })
120+
)
121+
})
122+
123+
it('cannot persist a batch position update', async () => {
124+
const { socket, handlers } = setup('sock-read-2', 'read')
125+
126+
await handlers['workflow-operation'](batchPositionUpdate())
127+
128+
expect(mockPersist).not.toHaveBeenCalled()
129+
expect(socket.emit).toHaveBeenCalledWith(
130+
'operation-forbidden',
131+
expect.objectContaining({ type: 'INSUFFICIENT_PERMISSIONS' })
132+
)
133+
})
134+
135+
it('still relays an UNCOMMITTED position update without persisting it', async () => {
136+
// The smooth-drag broadcast is deliberately ungated (it never persists), and
137+
// tightening the ACL must not turn it into an error.
138+
const { socket, handlers, toEmit } = setup('sock-read-3', 'read')
139+
140+
await handlers['workflow-operation']({
141+
...committedPositionUpdate(),
142+
payload: { id: BLOCK_ID, position: { x: 5, y: 6 }, commit: false },
143+
})
144+
145+
expect(mockPersist).not.toHaveBeenCalled()
146+
expect(toEmit).toHaveBeenCalledWith('workflow-operation', expect.anything())
147+
expect(socket.emit).not.toHaveBeenCalledWith(
148+
'operation-forbidden',
149+
expect.objectContaining({ type: 'INSUFFICIENT_PERMISSIONS' })
150+
)
151+
})
152+
})
153+
154+
describe('write member (positive control)', () => {
155+
beforeEach(() => {
156+
mockAuthorizeWorkflow.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
157+
})
158+
159+
it('persists a committed block position', async () => {
160+
const { handlers } = setup('sock-write-1', 'write')
161+
162+
await handlers['workflow-operation'](committedPositionUpdate())
163+
164+
expect(mockPersist).toHaveBeenCalledWith(
165+
WORKFLOW_ID,
166+
expect.objectContaining({ operation: 'update-position' })
167+
)
168+
})
169+
170+
it('persists a batch position update', async () => {
171+
const { handlers } = setup('sock-write-2', 'write')
172+
173+
await handlers['workflow-operation'](batchPositionUpdate())
174+
175+
expect(mockPersist).toHaveBeenCalledWith(
176+
WORKFLOW_ID,
177+
expect.objectContaining({ operation: 'batch-update-positions' })
178+
)
179+
})
180+
})
181+
})

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

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* - Edge cases and invalid inputs
88
*/
99

10+
import { ALL_SOCKET_OPERATIONS } from '@sim/realtime-protocol/constants'
1011
import {
1112
expectPermissionAllowed,
1213
expectPermissionDenied,
@@ -116,9 +117,10 @@ describe('checkRolePermission', () => {
116117
})
117118

118119
describe('read role', () => {
119-
it('should only allow update-position for read role', () => {
120+
it('should deny update-position for read role (it persists block coordinates)', () => {
120121
const result = checkRolePermission('read', 'update-position')
121-
expectPermissionAllowed(result)
122+
expectPermissionDenied(result, 'read')
123+
expectPermissionDenied(result, 'update-position')
122124
})
123125

124126
it('should deny batch-add-blocks operation for read role', () => {
@@ -137,9 +139,10 @@ describe('checkRolePermission', () => {
137139
expectPermissionDenied(result, 'read')
138140
})
139141

140-
it('should allow batch-update-positions operation for read role', () => {
142+
it('should deny batch-update-positions for read role (it persists block coordinates)', () => {
141143
const result = checkRolePermission('read', 'batch-update-positions')
142-
expectPermissionAllowed(result)
144+
expectPermissionDenied(result, 'read')
145+
expectPermissionDenied(result, 'batch-update-positions')
143146
})
144147

145148
it('should deny replace-state operation for read role', () => {
@@ -157,11 +160,11 @@ describe('checkRolePermission', () => {
157160
expectPermissionDenied(result, 'read')
158161
})
159162

160-
it('should deny all write operations for read role', () => {
161-
const readAllowedOps = ['update-position', 'batch-update-positions']
162-
const writeOperations = SOCKET_OPERATIONS.filter((op) => !readAllowedOps.includes(op))
163-
164-
for (const operation of writeOperations) {
163+
it('grants the read role NO operation at all', () => {
164+
// Every operation reaching this gate is persisted, so a read-only member must
165+
// hold none of them — including the position updates that used to be granted
166+
// here on the mistaken premise that they were ephemeral cursor sync.
167+
for (const operation of ALL_SOCKET_OPERATIONS) {
165168
const result = checkRolePermission('read', operation)
166169
expect(result.allowed).toBe(false)
167170
expect(result.reason).toContain('read')
@@ -209,28 +212,42 @@ describe('checkRolePermission', () => {
209212
})
210213

211214
describe('permission hierarchy verification', () => {
212-
it('should verify admin has same permissions as write', () => {
213-
const adminOps = ROLE_ALLOWED_OPERATIONS.admin
214-
const writeOps = ROLE_ALLOWED_OPERATIONS.write
215-
216-
// Admin and write should have same operations
217-
expect(adminOps).toEqual(writeOps)
218-
})
219-
220-
it('should verify read is a subset of write permissions', () => {
221-
const readOps = ROLE_ALLOWED_OPERATIONS.read
222-
const writeOps = ROLE_ALLOWED_OPERATIONS.write
223-
224-
for (const op of readOps) {
225-
expect(writeOps).toContain(op)
215+
// These assert the PRODUCTION ACL over the protocol's complete operation list.
216+
// They used to compare the shared test fixture against itself, which certified
217+
// whatever the fixture said — including, for a while, the read-role grants that
218+
// let a read-only member persist block positions.
219+
220+
it('grants admin everything write has, plus the admin-only operations', () => {
221+
for (const operation of ALL_SOCKET_OPERATIONS) {
222+
if (checkRolePermission('write', operation).allowed) {
223+
expect(checkRolePermission('admin', operation).allowed).toBe(true)
224+
}
225+
}
226+
// Strictly greater: at least one operation admin holds and write does not.
227+
const adminOnly = ALL_SOCKET_OPERATIONS.filter(
228+
(operation) =>
229+
checkRolePermission('admin', operation).allowed &&
230+
!checkRolePermission('write', operation).allowed
231+
)
232+
expect(adminOnly.length).toBeGreaterThan(0)
233+
})
234+
235+
it('grants read nothing, so it is trivially a subset of write', () => {
236+
const readAllowed = ALL_SOCKET_OPERATIONS.filter(
237+
(operation) => checkRolePermission('read', operation).allowed
238+
)
239+
expect(readAllowed).toEqual([])
240+
})
241+
242+
it('keeps the shared fixture in step with the production ACL', () => {
243+
// The fixture is a convenience mirror; drift between it and the real table is
244+
// what made the stale read grants look intentional.
245+
for (const operation of ALL_SOCKET_OPERATIONS) {
246+
const fixtureAllows = ROLE_ALLOWED_OPERATIONS.read.includes(
247+
operation as (typeof ROLE_ALLOWED_OPERATIONS.read)[number]
248+
)
249+
expect(fixtureAllows).toBe(checkRolePermission('read', operation).allowed)
226250
}
227-
})
228-
229-
it('should verify read has minimal permissions', () => {
230-
const readOps = ROLE_ALLOWED_OPERATIONS.read
231-
expect(readOps).toHaveLength(2)
232-
expect(readOps).toContain('update-position')
233-
expect(readOps).toContain('batch-update-positions')
234251
})
235252
})
236253

@@ -244,7 +261,7 @@ describe('checkRolePermission', () => {
244261
readAllowed: false,
245262
},
246263
{ operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false },
247-
{ operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: true },
264+
{ operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: false },
248265
{ operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false },
249266
{ operation: 'toggle-enabled', adminAllowed: true, writeAllowed: true, readAllowed: false },
250267
{ operation: 'update-parent', adminAllowed: true, writeAllowed: true, readAllowed: false },
@@ -265,7 +282,7 @@ describe('checkRolePermission', () => {
265282
operation: 'batch-update-positions',
266283
adminAllowed: true,
267284
writeAllowed: true,
268-
readAllowed: true,
285+
readAllowed: false,
269286
},
270287
{ operation: 'replace-state', adminAllowed: true, writeAllowed: true, readAllowed: false },
271288
]
@@ -337,21 +354,32 @@ describe('checkWorkflowOperationPermission', () => {
337354
expect(result.reason).toMatch(/revoked/i)
338355
})
339356

340-
it('denies writes after a downgrade to read but still allows position updates', async () => {
357+
it('denies every persisted operation after a downgrade to read, positions included', async () => {
341358
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
342359

343360
const denied = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
344361
expect(denied.allowed).toBe(false)
345362
expect(denied.role).toBe('read')
346363

347-
const allowed = await checkWorkflowOperationPermission(
364+
// A committed position update writes workflow_blocks, so a downgraded member
365+
// loses it too — this used to be allowed and was the escalation path.
366+
const position = await checkWorkflowOperationPermission(
348367
userId,
349368
workflowId,
350369
'update-position',
351370
'write'
352371
)
353-
expect(allowed.allowed).toBe(true)
354-
expect(allowed.role).toBe('read')
372+
expect(position.allowed).toBe(false)
373+
expect(position.role).toBe('read')
374+
375+
const batch = await checkWorkflowOperationPermission(
376+
userId,
377+
workflowId,
378+
'batch-update-positions',
379+
'write'
380+
)
381+
expect(batch.allowed).toBe(false)
382+
expect(batch.role).toBe('read')
355383
})
356384

357385
it('caches the role within the TTL to avoid a DB read on every operation', async () => {

apps/realtime/src/middleware/permissions.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,25 @@ const WRITE_OPERATIONS: string[] = [
5656
WORKFLOW_OPERATIONS.REPLACE_STATE,
5757
]
5858

59-
// Read role can only update positions (for cursor sync, etc.)
60-
const READ_OPERATIONS: string[] = [
61-
BLOCK_OPERATIONS.UPDATE_POSITION,
62-
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
63-
]
64-
65-
// Define operation permissions based on role
59+
/**
60+
* Operation permissions by role.
61+
*
62+
* `read` grants NOTHING. Every operation that reaches this gate is durably
63+
* persisted — `handlers/operations.ts` follows an allowed check with
64+
* `persistWorkflowOperation`, which writes `workflow_blocks` / `workflow` rows —
65+
* so granting a read-only member any entry here is a write, not a read.
66+
*
67+
* This previously listed `updatePosition` + `batchUpdatePositions` as readable
68+
* "for cursor sync", which they are not: live cursors ride their own
69+
* `cursor-update` event (`handlers/presence.ts`), and the smooth-drag broadcast
70+
* is the UNCOMMITTED position path, which returns before persisting and never
71+
* consults this table at all. The only thing the grant actually enabled was a
72+
* read-only collaborator permanently rewriting block coordinates.
73+
*/
6674
const ROLE_PERMISSIONS: Record<string, string[]> = {
6775
admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS],
6876
write: WRITE_OPERATIONS,
69-
read: READ_OPERATIONS,
77+
read: [],
7078
}
7179

7280
// Check if a role allows a specific operation (no DB query, pure logic)

0 commit comments

Comments
 (0)