fix(realtime): stop read-only members persisting block positions - #6174
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Production fix: Tests: New Reviewed by Cursor Bugbot for commit 0fd8b1f. Configure here. |
Greptile SummaryThe PR closes a realtime authorization gap by preventing read-only members from persisting block-position operations while preserving ephemeral drag broadcasts.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the previously reported stale read-role fixture now matches the production ACL and is covered by an exhaustive read-role synchronization assertion.
|
| Filename | Overview |
|---|---|
| apps/realtime/src/handlers/operations.test.ts | Adds integration-style handler tests proving read-only position writes are rejected, ephemeral updates remain relayed, and write-role persistence still succeeds. |
| apps/realtime/src/middleware/permissions.test.ts | Updates read-role expectations and verifies the shared read fixture against every canonical socket operation. |
| apps/realtime/src/middleware/permissions.ts | Removes the persisted position-operation grants from the read role, restoring the intended read/write boundary. |
| packages/testing/src/factories/permission.factory.ts | Corrects the shared read-role fixture to grant no socket operations, fully addressing the previously reported stale fixture. |
Sequence Diagram
sequenceDiagram
participant Client
participant Handler as Realtime operation handler
participant ACL as Permission middleware
participant DB as Workflow persistence
Client->>Handler: Position operation
alt Uncommitted drag update
Handler-->>Client: Relay ephemeral update
else Committed or batch update
Handler->>ACL: Check current workspace role
alt read role
ACL-->>Handler: Denied
Handler-->>Client: operation-forbidden
else write or admin role
ACL-->>Handler: Allowed
Handler->>DB: Persist block positions
end
end
Reviews (2): Last reviewed commit: "test(realtime): assert the role ACL agai..." | Re-trigger Greptile
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.
Re: the stale shared test fixture (4/5)Fixed in 0fd8b1f — good catch, and the underlying problem was worse than a stale value.
it('should verify read has minimal permissions', () => {
const readOps = ROLE_ALLOWED_OPERATIONS.read
expect(readOps).toHaveLength(2) // asserts the fixture, not the ACL
expect(readOps).toContain('update-position')
})That reads like a security assertion but certifies whatever the fixture says — it would have passed unchanged no matter what production granted, which is part of why the grants looked intentional. Changes:
Verified in both directions rather than just re-running green: reverting the production ACL fails 8 tests, and reverting only the fixture (the exact drift you flagged) fails the new pin test. Remaining duplication worth a follow-up, deliberately not done here to keep a security fix reviewable: |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 0fd8b1f. Configure here.
Summary
readroleblock.updatePositionandblocks.batchUpdatePositions, on the premise (stated in the comment) that they were ephemeral cursor sync. They are not — both are followed bypersistWorkflowOperation, whichUPDATEsworkflow_blocks.positionX/positionYand bumpsworkflow.updatedAt.readon a workspace could permanently rewrite the on-canvas coordinates of every block of every workflow in it: a write across the read/write boundary, with no UI affordance to undo beyond manual re-layout. The batch form does it for every block in one call and does not even require thecommitflag.cursor-updateevent (handlers/presence.ts), and the smooth-drag broadcast is the uncommitted position path, which returns before persisting and never consults the role table at all. Soreadneeds no grant — it is now[].Client-side this changes nothing for legitimate users:
nodesDraggableandonNodeDrag/onNodeDragStopare already gated oncanEdit, so a read-only member never emits these through the UI. Reaching the hole required a hand-crafted socket frame.Type of Change
Testing
handlers/operations.test.tsdrives the real handler and the real permission middleware (only the DB and the workspace authorizer are mocked) and asserts onpersistWorkflowOperation, since that is what actually writes the row: areadmember is refused for both the committed single and the batch form, and awritemember still persists both (positive control).readmember still relays and still does not persist, so tightening the ACL did not turn the ungated smooth-drag broadcast into an error.permissions.test.tscases that encoded the vulnerable behaviour ("should allow batch-update-positions operation for read role") are inverted, and the blanket case now asserts the read role holds no operation at all.tsc, lint, and all 12 CI audit scripts green locally.Notes for review
Considered and deliberately not done: gating
persistWorkflowOperationitself onpermissionSatisfies(role, 'write')as belt-and-braces. Every persist call inoperations.tsis already downstream of the single permission gate (the only bypass, the uncommitted position path, returns before persisting), andsubblocks.ts/variables.tscarry their own gates — so the runtime check would be pure redundancy, at the cost of an extra failure mode that surfaces to clients as a retryable persistence error. The invariant is instead pinned by the test assertingreadgrants nothing.Residual, out of scope and lower severity: a read-only member can still broadcast uncommitted position frames (phantom moves other collaborators see, which nothing persists and a refresh clears). That path is intentionally ungated for drag latency. Happy to close it in a follow-up if you'd like — it needs a synchronous check on the hot path rather than the existing async one.
Checklist