Security model
Trust chain
MSP operator (email/password)
→ Supabase Auth (user signs in)
→ Custom Access Token Hook (public.add_msp_id_claim) adds msp_id + msp_role to JWT
→ JWT cookie set by Supabase SSR
→ public.current_msp_id() reads the claim from request.jwt.claims
→ RLS policies use it to scope every SELECT/INSERT/UPDATE
Anything that breaks this chain results in zero rows visible to the user (deny-by-default).
Customer tenant Graph access is separate: per-tenant app registrations stored in Vault, invoked only from Edge Functions after tenant consent is complete.
RLS map
| Table | SELECT policy | Writes |
|---|---|---|
public.msp |
id = current_msp_id() | none (service-role only) |
public.msp_user |
msp_id = current_msp_id() | none (service-role only) |
public.tenant |
msp_id = current_msp_id() | INSERT/UPDATE allowed for own msp |
public.audit_log |
msp_id = current_msp_id() | INSERT for own msp; UPDATE/DELETE revoked entirely |
public.alert |
tenant_id ∈ own MSP's tenants | UPDATE (ack) for own MSP |
public.graph_credential |
tenant_id ∈ own MSP's tenants | none (service-role only) |
public.notification_channel |
msp_id = current_msp_id() | none (service-role only) |
public.notification_delivery |
channel ∈ own MSP | none (service-role only) |
public.graph_subscription |
(removed 2026-06 - table dropped; realtime drift uses nightly snapshot + manual resync) | - |
public.msp_change_request |
msp_id = current_msp_id() | none (service-role only) |
public.msp_change_request_outcome |
parent ∈ own MSP | none |
public.bg_check_state |
tenant_id ∈ own MSP's tenants | none |
public.tenant_onboarding_state |
(removed 2026-06) | - |
public.ratelimit_bucket |
service-role only | service-role only |
Per-tenant schemas (tenant_<uuid>.*) use tenant_scope_current_msp RLS. App code that touches them does so after explicit MSP ownership verification via loadTenantOrNotFound.
JWT claim mapping
add_msp_id_claim(event) hook:
- Inputs:
event.user_id,event.claims - Reads:
auth.usersemail →public.msp_user(or Entra OID for legacy rows) → msp_id, role - Output: claims object with
msp_idandmsp_roleadded (or unchanged if no msp_user row)
Vault usage
Two surfaces:
graph_credential.vault_secret_id→ per-tenant{client_id, client_secret}JSON (or just an opaque string)notification_channel.config_vault_secret_id→ channel-specific secret (webhook URL, Resend API key JSON, etc.)
Vault secrets are only readable by SECURITY DEFINER functions with EXECUTE revoked from authenticated/anon. App code never reads vault.secrets directly.
Audit immutability
audit_log is append-only at three levels:
- Grant layer - UPDATE and DELETE revoked from
public, authenticated, anon. Service role retains them deliberately, but app code never uses UPDATE/DELETE. - No app function ever mutates
audit_log. OnlywriteAuditLog(INSERT-only) and the DB-triggerwriteSystemAuditLog(INSERT-only) exist. - pgTAP test (
audit_log_immutable.sql) asserts the grant-layer behaviour and fails CI if anyone weakens it.
Edge Function defense in depth
Every Edge Function that touches Graph performs FOUR checks before calling Microsoft:
Authorizationheader present and starts withBearermsp_idJWT claim decodes and is non-null- Loaded
tenant.msp_idmatches the JWT claim - (For mutating functions)
msp_roleisadminorowner
RLS would already block cross-MSP queries; these checks ensure that a missing or stale claim never silently widens scope.
Rate limiting
/auth/login is rate-limited per source IP (10 attempts per minute) using a Postgres-backed token-bucket. The bucket key is login:<ip>; the worker on a cold start uses the bucket's existing tokens or refills if the window has elapsed.
Headers
Every response goes through proxy.ts (Next.js session proxy) which sets:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Content-Security-Policyallowing only self, Supabase, and Microsoft Graph origins
Redirect-validation
All next=... query params on /auth/login and /auth/callback are validated by safeNextPath. Protocol-relative URLs (//evil), absolute URLs (https://evil), and any path not starting with a single / are rejected and replaced with the fallback /dashboard.
Operator responsibilities
Things RLS / hooks can't enforce - the operator must:
- Keep the service-role key out of public bundles, public env vars, public logs
- Enable the Custom Access Token Hook (the entire RLS chain depends on it)
- Treat per-tenant Graph app secrets + Supabase service-role key as crown-jewel secrets
- The
kick_alert_dispatchtrigger authenticates via a scopedtrigger_dispatch_tokenin Vault - not the service-role key (which never touches the DB)
CA write bypasses
The standard CA write path (draft → dry-run → approval → applying → applied) has three intentional, tightly-scoped exceptions - emergency pause, auto-promotion, and CA supporting-object operations. Each has documented rationale, bounded blast radius, and its own audit requirement.
See Intentional CA write bypasses for the full treatment.