๐ก๏ธ Admin Allowlist + Custom Claim Migration
The canonical admin email allowlist and the custom-claim migration plan that will eventually replace it.
:::tip TL;DR
- Canonical allowlist (4 emails):
admin@acesense.io,akshay@akshaysarode.com,akshay.sarode@anilata.com,akshay.sarode18@gmail.com. - Mirrored in 3 places that must stay in sync.
- Preferred mechanism:
admin: trueFirebase custom claim. Email allowlist is the migration-safe fallback. - One-shot bootstrap: Settings โ Admin Claims โ "Set admin claims for all canonical emails" (idempotent). :::
๐ Where the allowlist livesโ
| File | What it gates | Role |
|---|---|---|
acesense-frontend/firestore.rules isAdmin() | Every Firestore read/write that needs admin | Server-side, authoritative |
acesense-auth-function/shared/admin-auth.ts ADMIN_EMAIL_ALLOWLIST | Every admin-only Cloud Function callable (adminRetryJob, adminSetUserPlan, adminSetUserSuspended, adminAdjustApiKeyBalance, adminRevokeSignedUrl, bootstrapAdminClaims) | Server-side, authoritative |
acesense-launchpad/src/config/firebase.ts ALLOWED_EMAILS | Launchpad sign-in gate (private fundraising tool) | Client-side check, defence in depth (Firestore rules also lock the project) |
acesense-admin/src/pages/Settings.tsx ADMIN_EMAILS | Display-only ("Authorized Admins" panel) | Informational, not enforcement |
The audit caught drift between these in April 2026 โ different files had different subsets. The canonical 4-email list above is now used everywhere.
๐ Why custom claimsโ
Email allowlist works but has 3 problems:
- Drift. Adding/removing an admin requires editing 3 files in 3 repos and deploying each.
- Email change โ admin change. If an admin changes their primary email in Firebase Auth, they silently lose admin until we update the rules.
- No audit trail. A claim grant/revoke is a discrete
admin_audit/{id}row; an email allowlist edit is a git commit nobody reviews.
admin: true Firebase custom claim solves all three:
- Single source of truth โ set on the user object, queryable via
request.auth.token.admin. - Tied to the user record, not the email.
- Grant/revoke is a one-line Admin SDK call that auto-audits.
The isAdmin() rules + requireAdmin() helper + launchpad gate all check the claim AND the email allowlist today, so we can flip everyone to claims without locking anyone out, then drop the email lists in a future commit.
๐ How to grant admin to a new userโ
One-time setup (already done by the bootstrap button)โ
Existing admins already have the claim if you've run the bootstrap. If a new email is added to the allowlist:
- Add the email to all 3 enforcement files:
acesense-frontend/firestore.rulesisAdmin()acesense-auth-function/shared/admin-auth.tsADMIN_EMAIL_ALLOWLISTacesense-launchpad/src/config/firebase.tsALLOWED_EMAILS
- Deploy:
firebase deploy --only firestore:rules,functions+cd acesense-launchpad && firebase deploy --only hosting. - Have the new admin sign in to the admin panel once (creates the Firebase Auth user record).
- Click Settings โ Admin Claims โ Set admin claims in the admin panel. Idempotent.
- The new admin needs to sign out + sign in (or
getIdToken(true)from the console) to refresh their token with the new claim.
Revoking adminโ
Two-step revoke (also idempotent):
-
Remove the email from all 3 enforcement files. Deploy.
-
From an admin Firebase Auth session, run:
await admin.auth().setCustomUserClaims(uid, { admin: false })await admin.auth().revokeRefreshTokens(uid) // kicks active sessions(No UI for this yet โ add an admin callable + button in Settings if it becomes a frequent operation.)
The user is now blocked at both layers (custom claim says admin === false, email allowlist no longer matches).
๐ค The bootstrap callableโ
Source: acesense-auth-function/admin/index.ts bootstrapAdminClaims.
// Auth: any existing admin (per email allowlist OR claim)
// Args: none
// Returns: { granted, alreadySet, missing, failed }
// Effect: setCustomUserClaims({ admin: true }) on every email in
// ADMIN_EMAIL_ALLOWLIST that has a Firebase Auth user record.
Idempotent โ running twice yields alreadySet for everyone. Audit row written to admin_audit/{id} per invocation.
UI button at https://admin.acesense.io/settings โ Admin Claims card โ "Set admin claims for all canonical emails."
If you prefer a CLI flow there's also acesense-auth-function/scripts/grant-admin-claim.ts that does the same thing via Application Default Credentials โ but it requires an ADC account with roles/firebaseauth.admin on the project, which the local gcloud admin@acesense.io login may or may not have. The Cloud Functions runtime always has it, so the callable is the path of least resistance.
๐๏ธ Migration plan: drop the email allowlistโ
Once we're confident the claim is set on every active admin and there's been time to verify (โฅ 1 admin login per email):
- Remove email lookups from
firestore.rules isAdmin()โ leave onlyrequest.auth.token.admin == true. - Remove email lookups from
auth-function/shared/admin-auth.ts requireAdmin(). - Remove email lookups from
launchpad/firebase.ts isAllowed(). - Add a
firestore.rulestest asserting that an authenticated user without the claim is denied. - Bump this doc to mark migration complete.
This is a single tightening commit โ but only after every existing admin has been observed making at least one authenticated request with the claim present. Until then, the email fallback is safety net.
๐ Relatedโ
- Access-control audit โ finding P2 ("allowlist drift") that drove this consolidation.
- ADR-0007: Agent commerce โ separate agent-key allowlist for the API surface (not admin).
- Threat model โ adjacent.