ADR 0011 — Platform admin via allowlist + service-role reads
Why /admin gates on a SQL-only platform_admins table and reads through the service-role client instead of new RLS policies.
- Status: Accepted
- Date: 2026-07-17
Context
The founder needs a platform-administrator area (/admin) with explicitly
separate access: overview KPIs, user/workspace management, a cross-workspace
audit viewer, and AI/docs ops. Two questions decide the architecture: how is
admin identity established, and how do admin reads cross tenant boundaries
that RLS otherwise enforces?
Decision
Identity: an allowlist table, granted only via SQL. platform_admins
(migration 0003) has a self-SELECT policy and no client write policies —
no session, including an admin's, can grant admin. The security-definer
helper is_platform_admin() is called under the caller's own JWT by
requirePlatformAdmin() (src/lib/admin/guard.ts).
Reads: service-role client behind the guard — not new RLS policies.
Admin pages read through createAdminClient() strictly after
requirePlatformAdmin(). We did not add or is_platform_admin() to core
table policies because that would (a) touch 8+ tables and add a function
call to every policy evaluation for every user, and (b) expose all tenant
data through the general PostgREST surface of an admin session. The
service-role path confines cross-tenant access to src/lib/admin/** and
src/app/admin/**, matching the existing invite/extraction pattern.
Guard layers: middleware redirect (unauth) → admin layout notFound()
(non-admin) → per-function guard → admin_* audit events (null
workspace_id) for every mutation. Deactivation is an auth ban
(ban_duration), never a delete — audit_events.user_id is NOT NULL, so
hard-deleting users would break the audit trail. Workspace deletion removes
Storage objects explicitly first; they do not cascade with the DB rows.
Consequences
- Positive: no privilege-escalation surface from the client; core RLS
untouched; the whole admin capability is auditable and greppable in two
directories; 404 keeps
/admininvisible to non-admins. - Negative: more code paths using the service-role client (mitigated by the guard-first convention); granting a new admin requires SQL access (accepted — it is intentionally rare and deliberate).