Supabase RLS mirroring
Mirror Keyring access into your Supabase Postgres so RLS checks keyring.has() / keyring.can() locally.
Hosted Keyring can't be called synchronously from inside an RLS policy (per-row HTTPS + secrets in the DB). Instead you keep a tiny keyring.access mirror in your database, fed by webhooks + a snapshot endpoint. RLS then reads it locally — as fast as author_id = auth.uid().
Run the mirror schema (once, in your project)
Copy templates/customer-mirror/schema.sql into your Supabase SQL editor. It creates:
keyring.access(subject_id, role_slug, perm_slug, expires_at)— single-org, noorganization_idcolumn. Expired rows stay and are filtered at read time.keyring.has('teacher')— role check forWITH CHECK/USING.keyring.can('invoices.refund')— capability check.
Then expose the schema to PostgREST or every mirror write fails with Invalid schema: keyring (and old applier versions swallowed it as fake 200s): Dashboard → Settings → API → Exposed schemas → add keyring alongside public. Takes effect within a minute, no redeploy needed.
create policy "teachers insert records"
on public.records for insert to authenticated
with check (keyring.has('teacher'));
create policy "refund own invoices"
on public.invoices for update to authenticated
using (author_id = auth.uid() and keyring.can('invoices.refund'));No client insert/update/delete policies are created on purpose — only your applier (service_role) writes.
Pick the default role (once, in Keyring)
Console Settings → Default role → choose the role every new signup lands in (e.g. player). Changing it later affects future signups only — existing grants are never touched. No default = provisioning refuses (fail-closed).
Sync signups → Keyring subjects
Deploy templates/customer-mirror/signup-sync.ts as an Edge Function (verify_jwt = false) and wire it to auth.users inserts (Dashboard → Database → Webhooks), with secrets KEYRING_SECRET_KEY / KEYRING_URL / SIGNUP_SHARED_SECRET (same value as the X-Signup-Key header on the webhook). It calls POST /api/v1/subjects/provision { subject: newUser.id } — no role slug crosses the wire, the server resolves your default. Auto-provisions the subject, idempotent, fail-closed. Or skip it and provisionSubject() lazily on first request.
Deploy the applier + register the webhook
- Deploy
templates/customer-mirror/applier.tsin your project askeyring-applierwith secretsKEYRING_WEBHOOK_SECRET(the once-shown value from creation),KEYRING_SECRET_KEY,KEYRING_URL. - Register it in Keyring (console Settings → Webhooks → Add Endpoint, or API):
await keyring.createWebhook({
name: "supabase mirror",
url: "https://<your-project>.supabase.co/functions/v1/keyring-applier",
events: ["grant.*", "subject.deleted"],
});
// → { id, ..., secret: "kr_ws_live_…" } — server-minted, shown ONCE.
// Copy it into your applier as KEYRING_WEBHOOK_SECRET now.Deliveries are HMAC-SHA256 signed (webhook-id / webhook-timestamp / webhook-signature); the applier verifies before writing. Grant events upsert with expires_at; grant.deleted / subject.deleted delete rows. Role-definition changes (role.permissions_updated) defer to reconcile.
Backfill + nightly reconcile
// Backfill one user (unknown subject → empty lists, safe to re-run)
const snap = await keyring.getSubjectAccess(user.id);
// → { subject, roles: ["teacher"], permissions: ["records.insert", ...] }Run a nightly cron over your user IDs calling getSubjectAccess() and replacing that subject's keyring.access rows. This heals any missed webhook (downtime, rotation) and absorbs role-definition fan-out. For users who signed up while no default was set (or while Keyring was down): provision auth users absent from keyring.provision_log — never present ones, so deliberately revoked users stay revoked.
Keep RLS ownership too
Keyring answers "may this role do this at all?"; RLS still answers "is this row theirs?" (author_id = auth.uid()). Use both. And re-check writes with keyring.check() server-side for instant revoke — JWT/mirror reads trail by seconds, the API never does.
Events catalog: grant.created/updated/deleted, subject.created/updated/deleted, role.created/updated/deleted, permission.created/updated/deleted, role.permissions_updated. Subscribe with exact names, prefixes (grant.*), *, or [] (all).