← All decisions

RLS is the security gate; the UI gates affordances by role as a UX hint, not a security control

accepted

0014 — RLS is the security gate; the UI gates affordances by role as a UX hint, not a security control

Context

Phase 2b introduced a three-role system (admin, member, guest) on org_members. Two enforcement layers became possible at once:

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:

  1. The JWT custom-access-token hook populates app_metadata.org_roles with { orgId: role } (migration 016, extending the original org_ids hook from migration 007). useCurrentRole() reads it client-side.
  2. 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.
  3. The API requireRole middleware 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.
  4. UI gates always start from a deny-by-default posture: when useCurrentRole() returns null (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:

Bad / accepted:

Out of scope