Skip to main content

๐Ÿ›ก๏ธ 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: true Firebase 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โ€‹

FileWhat it gatesRole
acesense-frontend/firestore.rules isAdmin()Every Firestore read/write that needs adminServer-side, authoritative
acesense-auth-function/shared/admin-auth.ts ADMIN_EMAIL_ALLOWLISTEvery admin-only Cloud Function callable (adminRetryJob, adminSetUserPlan, adminSetUserSuspended, adminAdjustApiKeyBalance, adminRevokeSignedUrl, bootstrapAdminClaims)Server-side, authoritative
acesense-launchpad/src/config/firebase.ts ALLOWED_EMAILSLaunchpad 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_EMAILSDisplay-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:

  1. Drift. Adding/removing an admin requires editing 3 files in 3 repos and deploying each.
  2. Email change โ‰  admin change. If an admin changes their primary email in Firebase Auth, they silently lose admin until we update the rules.
  3. 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:

  1. Add the email to all 3 enforcement files:
    • acesense-frontend/firestore.rules isAdmin()
    • acesense-auth-function/shared/admin-auth.ts ADMIN_EMAIL_ALLOWLIST
    • acesense-launchpad/src/config/firebase.ts ALLOWED_EMAILS
  2. Deploy: firebase deploy --only firestore:rules,functions + cd acesense-launchpad && firebase deploy --only hosting.
  3. Have the new admin sign in to the admin panel once (creates the Firebase Auth user record).
  4. Click Settings โ†’ Admin Claims โ†’ Set admin claims in the admin panel. Idempotent.
  5. 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):

  1. Remove the email from all 3 enforcement files. Deploy.

  2. 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):

  1. Remove email lookups from firestore.rules isAdmin() โ€” leave only request.auth.token.admin == true.
  2. Remove email lookups from auth-function/shared/admin-auth.ts requireAdmin().
  3. Remove email lookups from launchpad/firebase.ts isAllowed().
  4. Add a firestore.rules test asserting that an authenticated user without the claim is denied.
  5. 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.