Security: private-by-default boards with membership + capability share tokens#20
Conversation
…e tokens Replaces the free-for-all public-board model with explicit authorization, closing the core defect: a leaked/guessed board UUID granted read/write/delete on another tutor's lesson, uploaded materials were world-readable, and Realtime was unauthenticated. Server (0005_security_membership.sql): - board_members (owner/co_teach/draw/view) is the access source of truth; a board's creator is auto-enrolled as owner via trigger + backfill. - board_shares holds hashed, expiring, revocable capability tokens; create/redeem via SECURITY DEFINER RPCs (raw token returned once, only its SHA-256 stored). - RLS rewritten: reads for owner/member (is_public kept only as a transitional read path); writes/deletes require an editing membership. Removes boards_insert_any WITH CHECK (true) — board creation now requires auth. - board-assets flipped to a private bucket with membership-gated storage RLS. - realtime.messages policies authorize private Broadcast channels by membership. Client: - Assets served via short-lived signed URLs instead of a permanent public URL (storage.ts, MediaOverlayLayer async audio resolution). - Owner can mint an "Invite to edit" capability link; opening it redeems the token into a membership, minting an anonymous guest session first so account-less students still get a stable id for RLS (boardOwnership, Board, CollabBar). - Private Realtime channels gated behind VITE_REALTIME_PRIVATE (off by default) for a safe transport cutover after the Dashboard toggle + anon sign-in. Docs: SETUP/README/ROADMAP updated with the new model and required settings. Verified by applying 0001-0005 to a local Postgres with Supabase stubs and asserting allow/deny for owner, stranger, anon, and invited guest/viewer across boards, snapshots, assets, storage objects, and token redemption (valid / invalid / expired / revoked / non-owner-mint).
There was a problem hiding this comment.
💡 Codex Review
NotUX/apps/web/src/routes/Board.tsx
Line 63 in 4245b76
When VITE_REALTIME_PRIVATE=true and a signed-in user creates a new board, this initBoard call constructs the private SupabaseProvider before ensureBoardOwnership has inserted the boards row and owner membership. The provider subscribes only once (packages/sync/src/supabaseProvider.ts:76-92), while the new Realtime policy requires can_read_board(...), so the initial authorization can be denied and collaboration stays disconnected until a reload even though ownership is granted a few lines later.
For audio embeds, the signed URL is minted only when assetId changes, but assetSignedUrl uses a one-hour TTL. If a lesson remains open longer than that and the audio element later starts playback or needs another range request, it will keep using an expired URL and the controls fail until the component remounts; schedule a refresh before expiry or retry signing on media errors.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Why
This is the Stage 0 security gate from the requirements report. The old "free-for-all public board" model was the product's biggest liability: a leaked or guessed board UUID granted full read/write/delete on another tutor's lesson, uploaded materials (PDFs/images/audio — potentially a minor's) were world-readable, and Realtime was unauthenticated. For a product whose students may be minors this is a GDPR/COPPA/Children's-Code exposure, not just a bug.
Concretely, this closes:
boards_insert_any WITH CHECK (true)— anyone (incl. anon) could insert unlimited boards.is_publicgranting read and write/delete to anyone holding a UUID.board-assetsStorage bucket (world-readable + writable).What changed
Server —
supabase/migrations/0005_security_membership.sqlboard_members(owner/co_teach/draw/view) is the access source of truth; a board's creator is auto-enrolled asownervia trigger + backfill.board_sharesholds hashed, expiring, revocable capability tokens.create_board_share/redeem_share_tokenare SECURITY DEFINER RPCs — the raw token is returned once and only its SHA-256 hash is stored, so leaking the table doesn't leak access.is_publickept only as a transitional read path); writes/deletes require an editing membership. Board creation now requires auth.board-assetsflipped to a private bucket with membership-gated storage RLS.realtime.messagespolicies authorize private Broadcast channels by membership.Client
storage.ts, async audio resolution inMediaOverlayLayer).boardOwnership,Board,CollabBar).VITE_REALTIME_PRIVATE(off by default) for a safe transport cutover.Docs —
SETUP.md/README.md/ROADMAP.mdupdated, including the required Dashboard steps.0005_security_membership.sql(supabase db push).VITE_REALTIME_PRIVATE=trueto authorize channels by membership.Existing
is_publicboards keep working read-only during the transition; sharing moves to capability links. A later migration can drop theis_publicread path to make boards strictly private.Verification
Typecheck + production build pass. The migration was applied to a throwaway local Postgres with Supabase stubs (
auth/storage/realtime) and the RLS model was exercised end-to-end, asserting the intended allow/deny for owner, stranger, anon, and invited guest/viewer:drawtoken → gains read + autosave write; cannot create named snapshots ✅viewmember is read-only (write denied) ✅notux-board-<uuid>correctly and denies foreign/malformed topics ✅https://claude.ai/code/session_01Q9XUUYNL5QJtVQRr4iYFFE
Generated by Claude Code