feat(collab-c5): F-288 — Viewer read-only enforced at the data layer#112
Merged
Conversation
A Viewer holds the entity DEK (they can read) and can sign updates, so nothing at the crypto layer stopped their WRITES — receiveAndApply verified the sender signature + device-revocation but never the sender's ROLE, and roleAtLeast was used nowhere. So a Viewer's edits converged into every member's vault. This closes that: roles were decoration. - access-record.ts: `isAuthorizedWriter(doc, entityId, senderKeyBytes)` — true iff the sender is a current active member with role >= Editor. Compares DECODED KEY BYTES, because the wire header sender is base64url while access grants store std base64 — a string compare would silently drop a legitimate Editor. The owner self-grants Owner at provision, so no owner special-case is needed. - envelope-pipeline.ts: optional `authorizeWriter` on PipelineContext, consulted in receiveAndApply AFTER sig+AEAD verify (authenticated) and BEFORE apply; false => `Unauthorized` named drop. Optional + fail-open when absent keeps loopback/dev/soak paths (no access record) working. - live-sync-engine.ts: threads authorizeWriter from LiveSyncEngineContext into the per-frame pipeline ctx (beside isDeviceRevoked). - index.ts: production wiring — loads the persisted entity doc (same read isShared uses) and resolves the role; fail-closed on any error. +13 tests (isAuthorizedWriter: editor/owner allow, viewer/stranger/ revoked deny, promote-after-revoke; pipeline: authorized applies, unauthorized throws Unauthorized and never applies). Ride-along: collapse the root package.json workspaces array biome wanted (leftover from the #111 dependency install). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes a real authorization gap in multi-user collaboration. Part of 0.2.0 (live collaboration in the app UIs); the share dialog + roles already shipped, but roles were decoration: a Viewer holds the entity DEK (they can read) and can sign updates, and the receive path (
receiveAndApply) verified the sender signature + device-revocation but never the sender's role — so a Viewer's edits converged into every member's vault.roleAtLeastexisted but was used nowhere.The fix — authenticate, then authorize:
access-record.ts:isAuthorizedWriter(doc, entityId, senderKeyBytes)— true iff the sender is a current, active member with role ≥ Editor. Compares decoded key bytes, because the wire header sender is base64url while access grants store std base64 (a string compare would silently drop a legit Editor). The owner self-grants Owner at provision, so no owner special-case.envelope-pipeline.ts: optionalauthorizeWriterhook onPipelineContext, consulted after sig+AEAD verify and before apply;false⇒Unauthorizeddrop. Optional + fail-open-when-absent keeps loopback/dev/soak paths working (they carry no access record); production wires it.live-sync-engine.ts: threadsauthorizeWriterfromLiveSyncEngineContextinto the per-frame pipeline ctx (besideisDeviceRevoked).index.ts: production wiring — loads the persisted entity doc (same readisShareduses), resolves the role; fail-closed on any error.Coverage: both the live path and cold-restore go through the same
LiveSyncEngineinbound path, so a Viewer's tail frame on the durable node also drops on restore — verified in the restore-engine ("No frame handling is re-implemented here — that all lives in LiveSyncEngine").Security review (self, adversarial): no HIGH/MEDIUM findings; verified against 6 bypass vectors (Editor-key impersonation, self-grant forgery, base64url/base64 mismatch, cold-restore bypass, attacker-controlled header id, error fail-open) — all blocked. Enforcement is at each recipient (correct for CRDT authz): a modified Viewer client can corrupt only its own local copy, never propagate.
Tests: +13 (
isAuthorizedWriter: editor/owner allow, viewer/stranger/revoked deny, promote-after-revoke; pipeline: authorized applies, unauthorized throwsUnauthorizedand never applies). Full suite 14,655 passed; verify + typecheck + lint green.Ride-along: collapse the root
package.jsonworkspaces array biome wanted (leftover from the #111 dependency install).Plan: harness
docs/implementation-plan.mdCollab-C5 line corrected (PR #35) — F-288 ✅. Remaining 0.2.0 rungs: F-286 rotate-on-revoke, presence.🤖 Generated with Claude Code