# SECURITY-AUTH-WINDOW-SPEC.md
**Contract for the Auth Window: server-side RLS + MFA.** Lane A (Design) owns the front end + this spec; **Code owns all SQL, Auth config, and the `pure-mcp` changes.** Contract-first per the boneyard-collision lesson: build to this shape, don't redefine it. Everything **idempotent + reversible** (`create … if not exists`, `on conflict do update`, feature-flagged).

Project: `mbnakfrkfwxinhxlisyq` · branch `Testin.MikeSupabase`. Plan doc: `/pure-auth-window-plan.html`. Board epic: `epic-auth-window` (children `aw-1`…`aw-7`).

> **⚠ LOCKOUT IS THE #1 RISK. READ THIS FIRST.**
> - Keep a **break-glass admin**: a service-role path (or one hard-allow-listed superadmin) that bypasses RLS, open the entire time.
> - **Test every tier with a NON-admin hat** before enforcing — admins often pass by accident.
> - **Reads before writes. Opt-in before enforce.** Never enable a write-deny or AAL2-enforce in the same step you author it.
> - Every change lands behind a flag and has a one-line revert noted in its ticket.
> - **Do not start any step without the Operator present** (scheduled 7/7/26).

---

## 0. Ground truth (measured 7/6/26)
- **403 / 437** public tables have RLS **enabled**; **245** policies exist — but the app authenticates as **`anon`** (`pure-auth.js` sends `Bearer ANON`; `pure-mcp` runs `verify_jwt=false`) and current policies are permissive, so RLS is **not enforcing per-user**. The work is: send real identity, then tighten.
- Auth providers **email + Google are ON**. Supabase Auth supports native **TOTP + AAL2**. `pure-mfa.html` design preview exists.

---

## AW-1 — Client sends a real per-user JWT  *(Lane A · reversible: revert authedFetch)*
Change `PureAuth.authedFetch` (and any PostgREST/RPC caller) to attach the signed-in user's `access_token` (from `pure.auth.session`) as the `Authorization: Bearer` header. Anon key is used **only** for the deliberate public-read set (see tiers). 
**Guard:** invisible until policies tighten; test with the admin account first. No server change here.

## AW-2 — Org/role catalog  *(Lane A · additive)*
Additive tables the RLS helpers read. Shapes (adjust names to any existing equivalents — do **not** duplicate):
```sql
create table if not exists public.pure_orgs (
  id uuid primary key default gen_random_uuid(),
  name text not null, level text not null, -- 'platform'|'association'|'mls'|'brokerage'|'team'
  parent_id uuid references public.pure_orgs(id),
  mfa_required boolean not null default false,
  created_at timestamptz not null default now());
create table if not exists public.pure_roles (
  id text primary key, label text not null, rank int not null default 50);
-- pure_identity_hats already exists (18 rows): ensure columns user_id uuid, org_id uuid, role_id text
-- add FKs only if absent; backfill before enforcing NOT NULL.
```
**Guard:** additive only; seed/backfill before anything references it as NOT NULL.

## AW-3 — Policy helpers + READ-only RLS  *(Code · = ticket `aw-3-rls-read` / `t-rec-rls-readonly`)*
Helper functions (STABLE, `security definer`, `search_path=public`) read the JWT, never re-derive:
```sql
create or replace function public.jwt_uid() returns uuid language sql stable as
  $$ select nullif(auth.jwt()->>'sub','')::uuid $$;
create or replace function public.jwt_role() returns text language sql stable as
  $$ select coalesce(auth.jwt()->'app_metadata'->>'role', 'anon') $$;
create or replace function public.is_admin() returns boolean language sql stable as
  $$ select public.jwt_role() in ('admin','superadmin') $$;
create or replace function public.current_hats() returns setof public.pure_identity_hats language sql stable as
  $$ select * from public.pure_identity_hats where user_id = public.jwt_uid() $$;
create or replace function public.in_org(p_org uuid) returns boolean language sql stable as
  $$ select exists(select 1 from public.pure_identity_hats where user_id=public.jwt_uid() and org_id=p_org) $$;
```
**Tier taxonomy — classify every table into exactly one, author SELECT policies only:**
| Tier | Read rule | Examples |
|---|---|---|
| public-read | `using (true)` (keep anon) | brand_*, icons, faq, public listing microsites |
| authenticated-read | `using (auth.role()='authenticated')` | most app/domain data |
| owner/org-scoped | `using (in_org(org_id) or is_admin())` | deals, offers, cda, clearing, PII, money |
| admin-only | `using (is_admin())` | audit_log, *_settings, deploy registry, rate_limits |

**Guard:** SELECT policies only. Worst case is an over-restrictive read — revert the single policy. Roll tier by tier; after each, load a page as a **non-admin** hat and confirm expected visibility.

## AW-4 — WRITE policies + RPC-only privileged writes  *(Code)*
Add INSERT/UPDATE/DELETE policies scoped to `in_org()`/owner; **revoke anon write grants.** Privileged/cross-org writes go through `security definer` RPCs (mirror boneyard / `mcp_secpost_*` conventions: admin-check + `audit_log` row + idempotent).
**Guard:** break-glass admin open; stage per table-tier; keep a tested rollback script per tier.

## AW-5 — `pure-mcp` `verify_jwt=true`  *(Code)*
Require a valid JWT on authenticated endpoints; keep `chat.send` + public reads on a **separate anon-allowed** path/function. Deploy via Composio→GitHub (`SUPABASE_DEPLOY_FUNCTION`).
**Guard:** verify the public path still answers before/after; revert = `verify_jwt=false`.

## AW-6 — TOTP enroll (opt-in, non-enforcing)  *(Code + Lane A)*
Enable the Supabase TOTP factor. Wire `pure-mfa.html` → `PureAuth` (`mfa.enroll` / `mfa.challenge` / `mfa.verify`). Ship **backup codes** + admin **reset-MFA** in the same step.
**Guard:** enrollment is opt-in; **no AAL2 gate yet.** Nobody can be locked out by enrolling.

## AW-7 — Enforce AAL2  *(Code · highest lockout risk)*
Post-password, if the user has a factor **or** their org's `mfa_required` is true, require the TOTP step before minting the app session. Client SITE GATE checks `aal==='aal2'` for sensitive routes; RLS owner/admin policies may additionally check `auth.jwt()->>'aal'='aal2'`.
**Guard (mandatory order):** recovery codes + admin reset-MFA verified working → enforce for **admins only** → confirm admins can still get in → then per-org opt-in. Never blanket-enforce.

---

## Front end (Lane A, tracked here)
- `authedFetch`/`PureLive.rpc()` carry the JWT; graceful **401/403** handling (bounce to sign-in, or the existing "not authorized for this role" panel).
- Today's client **SITE GATE** (`pure-auth.js` + `pure-appshell.js`) is **layer 1 (UX)** — do not remove it; RLS is layer 2 (the real boundary).
- Scoreboards: `security-posture.html` (`rls`→have, `mfa`→enforced) and `pure-protection-audit.html` (page coverage) flip as each step lands.

## Reversibility cheat-sheet
| Step | One-line revert |
|---|---|
| AW-1 | authedFetch → send anon again |
| AW-3/4 | `drop policy` the offending policy (tier isolated) |
| AW-5 | `verify_jwt=false` redeploy |
| AW-6 | disable TOTP factor (enrollments dormant) |
| AW-7 | set `mfa_required=false` + client gate ignores aal |

## Change log
- 2026-07-06 — Lane A (Shattique): initial spec + plan doc `/pure-auth-window-plan.html` + board epic `epic-auth-window`. **Build starts 7/7/26 with Operator present.** No enforcement shipped yet.
