Skip to content

fix(realtime): enforce room access continuously, not only at join - #6170

Merged
waleedlatif1 merged 11 commits into
stagingfrom
worktree-realtime-room-authz
Aug 2, 2026
Merged

fix(realtime): enforce room access continuously, not only at join#6170
waleedlatif1 merged 11 commits into
stagingfrom
worktree-realtime-room-authz

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • File-doc, table, and workspace-list rooms were authorized once at JOIN and never again — a member whose workspace access was revoked or downgraded kept live collaborative write access (including durable Yjs document writes) for the whole lifetime of an already-open socket. The access-revalidation sweep explicitly skipped every non-workflow room; only workflow rooms were continuously authorized.
  • Sweep every room type now, authorizing each against its own resource (type:id decodes to the right workspace) instead of skipping anything that isn't a workflow.
  • One shared membership policy (ROOM_MEMBERSHIP_ACTIONS in a new dependency-free @sim/platform-authz/room-policy) backs both the join check and the sweep, so a file-doc room keeps requiring write in both and the two can't drift. That also makes a downgrade (write → read) evict from the editor while keeping the table room.
  • Per-frame gates on the hot paths: file-doc document frames and table cell selections check the cached permission synchronously (no DB wait per keystroke) and evict on a confirmed loss of access — dropping the room binding that gates the writes.
  • Re-check the cached decision immediately before a join commits, so a join that authorized just before a revocation can't re-enter the room the sweep just evicted it from.
  • A join's own cache write can no longer clobber a revocation recorded while its query was in flight (entry-identity guard, mirroring the resolver's existing in-flight guard).
  • New room-access-revoked event for non-workflow rooms; the file-doc client latches it as fatal and falls back to the read-only view instead of accepting keystrokes that go nowhere. Workflow's existing access-revoked payload is untouched.

Eviction stays fail-open on uncertainty: only a definitively-resolved insufficient permission evicts, so a DB blip or a hung query never kicks a live collaborator (the cold-cache fallback is now the room's own required level, not a static read that would have evicted every file-doc socket).

Type of Change

  • Bug fix (security)

Testing

  • 259 realtime tests + 43 client collaboration tests pass; tsc clean for apps/realtime, apps/sim, and both touched packages; biome clean; check-monorepo-boundaries and check-realtime-prune-graph pass.
  • Each of the 10 new tests was verified to fail with its guard removed.
  • Not live-tested with two browsers / two principals — the revocation half is covered by unit tests, not a live session.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

File-doc, table, and workspace-list rooms authorized once at JOIN and never
again, so a member whose workspace access was revoked or downgraded kept live
collaborative write access — including durable Yjs document writes — for the
whole lifetime of an already-open socket. The access-revalidation sweep
explicitly skipped every non-workflow room.

- sweep every room type, authorizing each against its own resource
- share one membership policy (ROOM_MEMBERSHIP_ACTIONS) between the join check
  and the sweep, so a file-doc room keeps requiring write in both
- gate file-doc document frames and table cell selections on the cached
  permission, evicting on a confirmed loss of access
- re-check the cached decision before a join commits, so a join that authorized
  just before a revocation cannot re-enter the room
- never let a join's own cache write clobber a revocation recorded mid-flight
- surface room-access-revoked to clients; the file-doc editor falls back to
  read-only instead of accepting keystrokes that go nowhere
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 1, 2026 19:17
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 2, 2026 12:12am

Request Review

@cursor

cursor Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes continuous authorization and eviction across security-critical realtime paths (collaborative writes, permission cache ordering, and mid-session eviction). Fail-open behavior on DB blips and extensive tests reduce deployment risk, but regressions could wrongly evict users or briefly allow unauthorized writes.

Overview
Closes a security gap where file-doc, table, and workspace-list rooms were authorized only at JOIN — revoked or downgraded members could keep live collaborative write access (including durable Yjs edits) until disconnect. Workflow rooms already had a periodic sweep; this extends that model to every room type.

The access revalidation sweep now decodes each Socket.IO room name (workspace-file-doc:…, table:…, etc.) and re-checks permission against that resource, evicting when the resolved level no longer satisfies the room (revocation or downgrade — e.g. write → read drops the document editor but can keep a table room). Cold-cache DB failures use each room’s own required level as fallback instead of a static read that would have wrongly evicted file-doc sockets.

Shared policy in @sim/platform-authz/room-policy (ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership) backs join, sweep, and per-frame gates so requirements cannot drift. Per-frame gates on file-doc Yjs frames and table cell selections peek the pod cache synchronously and evict on a confirmed denial without blocking the relay on DB. Join paths re-resolve permission immediately before committing membership so a stale in-flight allow cannot re-enter after a sweep records a revocation.

A read-ticket ordering on the permission cache (beginRoomPermissionRead / commitRoomPermission) stops a slower join-time allow from overwriting a fresher sweep denial. New room-eviction module centralizes non-workflow eviction (room-access-revoked wire event) and hands failed Redis presence cleanups to the sweep’s retry lane. The file-doc client treats room-access-revoked as fatal and falls back to read-only; workflow keeps access-revoked unchanged.

Reviewed by Cursor Bugbot for commit 54fdd4f. Configure here.

Comment thread apps/realtime/src/handlers/workspace-invalidation-room.ts
The sweep and both per-frame gates each open-coded emit + leave + local-state
cleanup. Route them all through evictSocketFromRoom so they cannot diverge on
what eviction means; workflow keeps its historical access-revoked payload.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/realtime/src/middleware/permissions.ts
The workspace-files / workspace-tables joins committed straight from their
authorize result, so a join that authorized just before a revocation could put
the socket back in a room the sweep had already evicted it from. Mirrors the
guard the file-doc and table joins already had.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Two authorizations can start in one order and finish in the other, so the
decision written last can come from the older read. A join that authorized
before a revocation but returned after the sweep's denial would bury it,
handing the socket another full cache TTL of access. Every writer now takes a
monotonic ticket before it queries and yields only to a later-started read.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Tests can express the same setup with commitRoomPermission + a read ticket, so
the cache has exactly one write path and no export without a production caller.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR continuously revalidates authorization for every realtime room type and adds shared join, sweep, and per-frame permission enforcement.

  • Adds room-specific membership policies and permission caching.
  • Evicts revoked file-document and table participants while reconciling local and presence state.
  • Adds a client-visible non-workflow revocation event and read-only fallback for file documents.
  • Expands tests for revocation, downgrade, join races, and deferred cleanup.

Confidence Score: 4/5

The PR is not yet safe to merge because table collaborators can retain stale peer-visible presence when removal succeeds but the subsequent broadcast fails.

A reply reports the table cleanup issue as fixed, but the current retry still stores no broadcast-pending state: after successful removal and a failed broadcast, the next attempt sees the cleared mapping, treats the redundant false removal as completion, and never rebroadcasts the updated roster.

Files Needing Attention: apps/realtime/src/access-revalidation.ts, apps/realtime/src/handlers/tables.ts

Important Files Changed

Filename Overview
apps/realtime/src/access-revalidation.ts Extends authorization sweeps and deferred cleanup to every room type, but the existing broadcast-after-removal retry defect remains.
apps/realtime/src/handlers/tables.ts Adds cached per-frame authorization and retry handoff for table eviction, while the handoff still loses broadcast-only cleanup state.
apps/realtime/src/handlers/file-doc.ts Adds cached write gating, join-time revalidation, and local-state cleanup for revoked document collaborators.
apps/realtime/src/middleware/permissions.ts Generalizes cached permission resolution across room types with ordered commits protecting against stale in-flight results.
apps/realtime/src/handlers/room-eviction.ts Centralizes non-workflow eviction events and handler-local cleanup registration.
packages/platform-authz/src/room-policy.ts Defines shared room membership requirements used consistently by join and continuous enforcement.
packages/realtime-protocol/src/events.ts Adds the generic non-workflow room-access-revoked wire contract.
apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts Handles fatal document-room revocation by stopping collaboration and falling back to read-only behavior.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Handler as Realtime handler
  participant Cache as Permission cache
  participant Sweep as Revalidation sweep
  participant Presence as Room presence
  Client->>Handler: Join room
  Handler->>Cache: Record authoritative permission
  Handler-->>Client: Join accepted
  Sweep->>Cache: Refresh room permission
  alt Access remains sufficient
    Sweep-->>Client: Membership retained
  else Access revoked or downgraded
    Sweep-->>Client: Revocation event
    Sweep->>Handler: Drop room-local binding
    Sweep->>Presence: Remove presence
    Presence-->>Client: Broadcast updated roster
  end
Loading

Reviews (6): Last reviewed commit: "fix(realtime): close the table join wind..." | Re-trigger Greptile

Comment thread apps/realtime/src/handlers/tables.ts
Comment thread apps/realtime/src/handlers/file-doc.ts Outdated
Evicting leaves the Socket.IO room synchronously, which is also how the sweep
discovers work — so a presence removal failing in the per-frame path could never
be retried and left a ghost collaborator until disconnect. Failed (or
unconfirmed) removals now hand off to the sweep's existing cleanup lane instead
of a second retry loop.
…e cache

The pre-commit recheck peeked the role cache, which reports an EXPIRED entry as
unknown and fails open — so a join stalled longer than the cache TTL could
re-enter a room the sweep had already evicted it from, including a file-doc room
where the next cold-cache frame is accepted as a durable write. All three joins
now re-resolve the way the workflow join always has; it is normally a cache hit,
since the join's own authorize just warmed it.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/realtime/src/handlers/workspace-invalidation-room.ts
The access re-resolve added in the previous commit sat AFTER the generation /
superseded guard in the table and workspace-list joins, so a leave or a newer
join landing during that await no longer cancelled the stale join — it would go
on to leave the room the client had switched to and commit the abandoned one.
The guard is now the last thing before the commit in all three handlers, as it
already was for file-doc and workflow.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/realtime/src/access-revalidation.ts
check:utils bans the inline new Promise(setTimeout) form; the two stalled-join
tests were the only new offenders.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/realtime/src/handlers/tables.ts
A table switch left the previous room before the access re-check ran, so a
denial there aborted the join and left the client in no table room at all —
silently dropped from one it may still be allowed to occupy. The leave now
happens after the re-check, matching the file-doc and workspace-list joins.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/realtime/src/handlers/tables.ts Outdated
Moving the prior-room leave after the access re-check left Redis awaits between
that check and socket.join, and superseded() only watches the join generation —
so a sweep revocation landing in that window could still put a revoked socket
back in the room. A synchronous cache peek immediately before the commit closes
it without reintroducing the await; the authoritative resolve moments earlier
wrote a fresh entry, so a differing read IS the revocation being guarded.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 54fdd4f. Configure here.

@waleedlatif1
waleedlatif1 merged commit 5686b7b into staging Aug 2, 2026
27 checks passed
@waleedlatif1
waleedlatif1 deleted the worktree-realtime-room-authz branch August 2, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant