PURE · Identity & Access · Implementation Plan

Auth Window: Server-side RLS + MFA

The full plan to turn today's client-side login gate into a real security boundary — per-user identity, Row-Level Security as the data boundary, and TOTP/AAL2 multi-factor at sign-in. Sequenced so no one gets locked out, every step reversible.

Owner: Lane A (Design) · Backend: Code / pure-mcp · Approvals: Mike · Supabase project mbnakfrkfwxinhxlisyq · branch Testin.MikeSupabase · CT 7/6/26

Where things stand Cardinal rule A · Real identity B · RLS C · MFA + AAL2 D · Front end Rollout order Risks MFA lock-out solution Blast radius & rollback Who does what

0Where things stand

Ground truth from the live database and Auth config, 7/6/26.

408 / 447Tables with RLS enabled
250Policies (only 216 tables)
anonHow the app authenticates today
3 / 36Users with a verified MFA factor

The catch: RLS is "on" but not enforcing

Most tables already have RLS enabled with policies — but the app talks to Postgres with the anon key (pure-auth.js sends Bearer ANON on every request; pure-mcp runs verify_jwt=false), and the existing policies are permissive enough to let anon read/write so the prototype works. RLS is a gate with the door propped open.

So this is less "build from zero" and more flip the app from anon to real per-user identity, then tighten the policies. MFA is similar: Supabase Auth has native TOTP/AAL2 and the pure-mfa.html design preview exists — it needs enabling and wiring, not inventing.

Two numbers that define the whole risk

1The cardinal rule

Sequence, or you lock everyone out.

Sign-in works verify staff can actually authenticate THEN RLS read-only THEN RLS writes THEN MFA enforcement. Never enable enforcement before identities can log in, or staff get blanked mid-session. Every step lands behind a flag and is reversible, and a break-glass service-role admin path stays open throughout.

AMake identity real Design Code

Prerequisite for everything — nothing below means anything until the app sends a real user token.

A1
Switch the client from anon to per-user JWT. Today PureAuth.authedFetch sends Bearer ANON. Send the signed-in user's access_token (already in pure.auth.session) for PostgREST/RPC calls, anon only for genuinely public reads. This one change makes RLS start meaning something.
A2
Every staff member gets a real auth.users row + role in app_metadata (server-controlled), not user_metadata (user-editable). Migrate the role source so RLS can trust it.
A3
Land the org/role catalog (pure_orgs, pure_roles, pure_identity_hats with FKs — the model in the Org Hierarchy doc). Policies reference these to answer "can this user see this row?" without hard-coding role strings.

BRLS — the data boundary Code

Tighten the propped-open door. Reads before writes, tier by tier.

B4
Audit the 245 existing policies. Most are prototype-permissive (using (true) / anon-allowing). Sort every table into: public-read (brand, icons, FAQ), authenticated-read (most app data), owner/org-scoped (deals, CDA, money, PII), admin-only (audit_log, settings, deploy registry).
B5
Write policy helpers — SQL functions current_hats(), has_role(), in_org() that read the JWT (auth.jwt(), app_metadata.role) against pure_identity_hats. Every policy calls these instead of re-deriving.
B6
Roll out READ policies first, in read-only mode (the t-rec-rls-readonly ticket), table-tier by tier. Worst case is an over-restrictive read — instantly revertible, can't destroy data.
B7
Then WRITE policies (INSERT/UPDATE/DELETE), scoped to owner/org; route privileged writes through SECURITY DEFINER RPCs (the boneyard / security-posture pattern). Anon loses all write grants.
B8
Flip pure-mcp to verify_jwt=true for authenticated endpoints; keep chat.send / public reads on a separate anon-allowed path. Deploy via ComposioGitHub.

CMFA + AAL2 Code Design

Step-up authentication. The infrastructure exists — enable and wire it.

C9
Enable the TOTP factor in Supabase Auth; wire pure-mfa.html's enroll / challenge / verify to PureAuth (mfa.enroll, mfa.challenge, mfa.verify). The design preview already exists.
C10
Per-org mfa_required flag. Admins and any opted-in org require AAL2.
C11
Gate on AAL2, not just AAL1. After password login, if the user has a factor (or their org requires one), force TOTP before minting the app session. The client SITE GATE checks aal === 'aal2' for sensitive routes; RLS policies check auth.jwt()->>'aal' server-side.
C12
Recovery + enforcement UX: backup codes, "remember this device 30 days," admin reset-MFA. WebAuthn / FIDO2 hardware keys as a later add (posture board already targets it).

DWire the front end Design

D13
Today's SITE GATE becomes layer 1 (UX). Add a JWT-carrying authedFetch/PureLive.rpc() and graceful 401/403 handling — bounce to sign-in, or the "not authorized for this role" panel (the role-guard already built).
D14
security-posture.html is the live scoreboardrls flips partialhave and mfaenforced as each lands. pure-protection-audit.html tracks page coverage.

2Rollout order

Each row is its own reversible checkpoint — one per turn.

#CheckpointGuard
1Client sends real JWT (A1)Test with admin first
2Org/role catalog live (A3)Additive
3Policy helpers + READ-only RLS, tier by tier (B5–B6)Watch over-restriction
4WRITE policies + RPC-only privileged writes (B7)Break-glass admin
5verify_jwt=true on pure-mcp (B8)Split public path
6TOTP enroll live, opt-in (C9)Non-enforcing
7AAL2 enforced — admins, then per-org (C10–C11)Recovery codes ready

3Risks & gotchas

4MFA lock-out — the solution Code Design

The trap: 36 users, 3 factors. A global “require AAL2” flip strands 33 people at a dead screen. The fix is to never flip — enforcement becomes a self-enrollment grace gate with a pre-flight guard that makes lock-out structurally impossible.

S1
Enroll, don’t block. When a user’s org requires MFA and they have no verified factor, the gate does not bounce them to a dead end — it routes them into pure-mfa.html enrollment, which they complete in-session and continue. AAL1 is enough to reach the enrollment screen; AAL2 is required for everything else. A factorless user can always self-serve out of the trap — there is no state where you can log in but cannot enroll.
S2
Grace window, not a cliff. Turning on mfa_required for an org starts a countdown (default 14 days). During grace, AAL1 users keep full access behind a persistent “Set up MFA before <date>” banner. After expiry, enrollment is required before continuing — still self-serve in the same session, still never a lockout.
S3
Pre-flight guard — the structural fix. A SECURITY DEFINER RPC mfa_enforce(org) refuses to arm enforcement unless: (a) ≥1 admin in that org has a verified factor, (b) the enrollment route is reachable, (c) recovery-code issuance is on. You literally cannot enable MFA for an org that would lock itself out.
S4
Cohort rollout. PURE platform org (admins) first — the 3 who already have factors verify the full enroll challenge verify reauth loop end-to-end, remaining admins enroll during grace — then org by org. Never all-at-once.

Break-glass — permanent escape hatches (kept open throughout)

5Blast radius & rollback

Exactly what changes behavior at each step, and how to undo it. Login itself never breaks — you can always authenticate; the risk is “logged in but blank,” which is always reversible.

ChangeWhat could breakRollback
Client sends real JWT instead of anon (A1)Any page reading a table whose policy only allowed anon stops returning rows once the token changes.Revert authedFetch to anon; one file, one deploy.
Tighten pure_identity_hats (anon authenticated)Hats dropdown + Act-As go blank if read before JWT attaches or org-scope too strict. Highest-touch table — test hardest.Restore the permissive policy (kept as a named migration); instant.
Lock pure_properties (public authenticated)Public property search / listing microsites stop working for logged-out visitors.Keep a deliberate public-read policy set; decide per-table, don’t blanket-deny.
Enable RLS on the 39 still-off tablesInstant blank if no SELECT policy exists first.Add policy first, then enable; or disable RLS on that table.
verify_jwt=true on pure-mcp (B8)Public paths (chat.send, board reads) 401 if not split onto an anon path.Split public endpoints first; redeploy prior function version via Composio.
AAL2 enforcement (C10–C11)Factorless users routed to enrollment (by design, not a lockout — see §4).mfa.enforcement kill switch off; or admin_mfa_reset.
The one rule that guarantees no permanent lockout.

Two doors stay outside the app’s own auth at all times: the Supabase dashboard (owner login) and the service_role key (bypasses all RLS, powers MCP + crons). As long as both are retained, any soft-lock is a few-minutes fix, never a dead end. Do not route either of those behind the app gate.

6Who does what

OwnerScope
CodeAll SQL (policies, helpers, RPCs), verify_jwt, Auth TOTP config, app_metadata role migration.
MikeConfirm any paid add-ons (none strictly required for RLS/MFA), approve the enforcement cutover.
DesignJWT-aware authedFetch, 401/403 UX, MFA screens wired, org/role catalog tables, security-posture + protection-audit scoreboards, the rollout checkpoints.

This document is the plan of record for the auth window. The natural next step is a board epic (parent ticket + the 7 rollout checkpoints as children) and a contract-first SECURITY-AUTH-WINDOW-SPEC.md that Code builds the SQL against — keeping the reversible, one-checkpoint-per-turn cadence.

PURE MLS Technologies · Auth Window Implementation Plan · prepared by Lane A (Shattique) · 7/6/26