Skip to content

feat(collab-c5): F-288 — Viewer read-only enforced at the data layer#112

Merged
th3-br41n merged 1 commit into
mainfrom
feat/collab-c5-share
Jul 8, 2026
Merged

feat(collab-c5): F-288 — Viewer read-only enforced at the data layer#112
th3-br41n merged 1 commit into
mainfrom
feat/collab-c5-share

Conversation

@th3-br41n

Copy link
Copy Markdown
Contributor

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. roleAtLeast existed 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: optional authorizeWriter hook on PipelineContext, consulted after sig+AEAD verify and before apply; falseUnauthorized drop. Optional + fail-open-when-absent keeps loopback/dev/soak paths working (they carry no access record); production wires it.
  • 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), resolves the role; fail-closed on any error.

Coverage: both the live path and cold-restore go through the same LiveSyncEngine inbound 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 throws Unauthorized and never applies). Full suite 14,655 passed; verify + typecheck + lint green.

Ride-along: collapse the root package.json workspaces array biome wanted (leftover from the #111 dependency install).

Plan: harness docs/implementation-plan.md Collab-C5 line corrected (PR #35) — F-288 ✅. Remaining 0.2.0 rungs: F-286 rotate-on-revoke, presence.

🤖 Generated with Claude Code

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>
@th3-br41n th3-br41n merged commit 9a354ff into main Jul 8, 2026
3 checks passed
@th3-br41n th3-br41n deleted the feat/collab-c5-share branch July 8, 2026 06:15
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