Audit log + impersonation
Append-only by grant + RLS
audit_events has RLS enabled with a SELECT policy that gates on role (admin+ sees all, member sees own actor rows, guest sees none). For writes, only the dedicated cb_audit_writer Postgres role has an INSERT grant; no role anywhere has UPDATE or DELETE on the table.
The Next.js app opens a separate postgres-jsconnection via PG_AUDIT_WRITER_URLfor audit writes. Compromise of the service-role key doesn't let an attacker rewrite history.
Doubly-logged impersonation
Every audit_events row has an actor_id (the apparent user) and an optional impersonator_id (the admin who initiated impersonation, when relevant). The auditLog writer reads both from the active JWT claims via getCurrentActor:
- When the
cb_impersonatecookie is set,actor_id = subandimpersonator_id = app_metadata.impersonated_by. - Otherwise,
actor_idis the session user andimpersonator_idis null.
A pure-function guard refuses any insert where impersonator_id is set but the JWT's aud claim isn't impersonation, or where impersonator_id == actor_id. A DB-level CHECK (audit_events_no_self_impersonation) backstops the latter regardless of role.
Banner is UI, audit log is truth
During impersonation, every authenticated page renders a persistent red banner driven by the presence of the cb_impersonate cookie. If a user clears the cookie via DevTools, the banner disappears — but the audit log still records every action taken under that session. The banner is a UI nicety; the audit log is the source of truth.
Hard expiry at 60 minutes
The impersonation JWT has a fixed 60-minute TTL signed with SUPABASE_JWT_SECRET. No refresh path exists. On expiry the verify returns null, the cookie is silently cleared, and the admin's normal session resumes immediately (their sb-* cookies were preserved untouched).
See Test results for the green run of tests/06_audit_integrity.sql and tests/11_impersonation_visibility.sql.