Security & Identity
Authentication & Authorization
Authentication flow
/auth (email + password)
│
▼
login-rate-limit (pre-check)
│ │
│ 429 │ ok
▼ ▼
block supabase.auth.signInWithPassword
│
▼
login-rate-limit (post-check: record outcome)
│
▼
Session established (AAL1)
│
▼
/app/* beforeLoad → check AAL + TOTP factor
│ │
│ enrolled? │
▼ ▼
/mfa-setup AAL2? — yes → /app, no → /mfa-setup (step-up)
Implementation:
src/routes/auth.tsx— sign-in UI and rate-limit wiring.src/routes/mfa-setup.tsx— TOTP enrolment and step-up challenge.src/routes/app.tsx—beforeLoadenforces AAL2.supabase/functions/login-rate-limit/index.ts— IP-based lockout.
Multi-factor authentication
- Factor type: TOTP (RFC 6238).
- Library:
supabase.auth.mfaplus theqrcodepackage for local QR generation (the TOTP secret never leaves the browser). - Enrolment: required on first sign-in; cannot be skipped.
- Step-up: AAL1 sessions are redirected to
/mfa-setupto satisfy the gate. - Recovery: managed by the tenant administrator via Supabase Auth; documented in the Administrator Guide.
Session management
| Property | Value |
|---|---|
| Token storage | localStorage via @supabase/supabase-js |
| Refresh | Automatic, rotation on use |
AAL level required for /app/* | aal2 |
| Idle timeout | 30 minutes → silent signOut(); 8-hour absolute session maximum |
| Sign-out on logout | Clears local session and refresh token |
RBAC model
Roles are an enum in Postgres and live in a dedicated public.user_roles table.
create type public.app_role as enum (
'owner', 'admin', 'auditor',
'risk_officer', 'compliance_officer', 'model_owner', 'user'
);
Role checks use a SECURITY DEFINER function to avoid recursive RLS:
create or replace function public.has_role(_user_id uuid, _role app_role)
returns boolean language sql stable security definer
set search_path = public
as $$
select exists (
select 1 from public.user_roles
where user_id = _user_id and role = _role
)
$$;
src/lib/rbac/roles.ts exports the TypeScript enum that mirrors the Postgres type.
Permission matrix
| Capability | owner | admin | auditor | risk_officer | compliance | model_owner | user |
|---|---|---|---|---|---|---|---|
| Read dashboards | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Register / edit AI models | ✓ | ✓ | – | – | – | ✓ | – |
| Run AI assessments | ✓ | ✓ | – | ✓ | – | ✓ | – |
| Approve AI deployment | ✓ | ✓ | – | ✓ | ✓ | – | – |
| Manage controls / compliance | ✓ | ✓ | – | – | ✓ | – | – |
| Issue / revoke access grants | ✓ | ✓ | – | – | – | – | – |
| Attest access reviews | ✓ | ✓ | ✓ | – | – | – | – |
| Read full audit log viewer | ✓ | – | ✓ | – | – | – | – |
| Switch role (governance only) | ✓ | ✓ | – | – | – | – | – |
| Read GDPR requests | ✓ | – | – | – | ✓ | – | – |
| Edit security / platform config | ✓ | – | – | – | – | – | – |
The matrix above is enforced at three layers: route gating (TanStack Start beforeLoad), server function middleware (requireSupabaseAuth + has_role check), and database RLS policies.
Audit
Every authentication and authorisation event writes to:
public.sec_login_attempts— sign-in attempts (IP, outcome, timestamp).public.access_audit_log— role changes, grant issue/revoke, review attestations.
Both are append-only from the application's perspective; service_role only writes.