0014 — RLS is the security gate; the UI gates affordances by role as a UX hint, not a security control
- Status: accepted
- Date: 2026-05-08
- Deciders: Derek
Context
Phase 2b introduced a three-role system (admin, member, guest) on org_members. Two enforcement layers became possible at once:
- Database — RLS policies (the existing keystone gate per ADR 0002 / 0009). Helpers
app.user_can_write_in_org()andapp.is_org_admin()express the role check. Every write on every table either delegates to one of these helpers or is gated by an explicitrole = 'admin'clause. - Client — UI affordances that show or hide based on the caller’s role (e.g. don’t render “New thread” for a guest).
A naive product would conflate these — “if the user can’t do X, just don’t render the button, problem solved.” That posture is brittle: any direct API call (curl, browser console, second client) bypasses the UI and the system has no defense.
We need to be explicit about which layer is the security control and which is window dressing.
Decision
RLS is the security gate. Period. Every write that should be admin-only is a policy that would reject a non-admin even if the API and UI stripped every authorization check. The keystone test (packages/db/test/rls-isolation.spec.ts) asserts this directly — guests cannot insert into forum_threads, forum_replies, or cms_entries; cross-org reads are blocked; recipient invitation reads scope to the JWT email.
The UI gates affordances by role as a UX hint, not a security control. Specifically:
- The JWT custom-access-token hook populates
app_metadata.org_roleswith{ orgId: role }(migration 016, extending the original org_ids hook from migration 007).useCurrentRole()reads it client-side. - Pages hide affordances the caller can’t use: ForumList hides “New thread” for guests; ThreadDetail hides the reply form for guests; People hides the Pending invitations + Invite cards for non-admins.
- The API
requireRolemiddleware exists for clean 403 responses but is also non-load-bearing — its only job is to short-circuit before a round trip when the policy would have rejected anyway. - UI gates always start from a deny-by-default posture: when
useCurrentRole()returnsnull(no role claim, e.g. an old JWT predating migration 016), affordances are hidden. Old tokens auto-refresh within an hour; the user is never permanently locked out.
Consequences
Good:
- Defense in depth without security through obscurity. The lock is at the database; the UI just doesn’t show locked doors.
- Adding a fourth role later (e.g.
coordinator) requires updating the helpers + the JWT hook + the gating components — no security audit required because the security layer is one level deeper than the UI. - Tests are clean: the keystone test is the security spec; component tests cover the UI hint independently.
Bad / accepted:
- Two writes for any new write affordance: an RLS policy and a UI gate. Discipline cost.
- Stale JWTs (between role change and next refresh) show stale affordances. Acceptable: users can refresh manually; auto-refresh resolves within the hour; RLS still rejects writes from a stale-claim attacker.
Out of scope
- A configurable per-tenant role registry (
packages/permissionswith an IMPLIES chain) is roadmap-Later. Theorg_members.permissions JSONBcolumn is reserved for it. - Rich permission expressions (e.g. “can edit if author OR admin OR moderator”) use composition of the existing helpers + author checks; we don’t introduce a DSL.
- Server-side rendering of role-aware UI. The portal is SPA; there’s no surface for SSR-time role decisions yet.