Admin portal sign-in: tier fallthrough scenario matrix โ
Issues: #924 (an admin with no portal password gets full access indefinitely via the legacy sign-in tier), #904 (Agent Probe test-admin has no password set, so admin-portal browser checks are blocked) Branch: fix/924-admin-auth-pairWritten: 2026-08-20, before any code, per the scenario-matrix skill.
Admin sign-in is the security core of the admin portal and it cannot be smoke-tested into correctness: the interesting cells are combinations of credential state, role, ban state, and browser attestation that do not occur naturally in a manual pass. This document decides every cell first. It is the test for the cells that no live run reaches.
The system as it stands โ
apps/admin/src/App.jsx handleSignInWithEmail tries three tiers and falls through:
| Tier | Endpoint | Credential | Gated by |
|---|---|---|---|
| 1 | POST /auth/admin/signin | adminProfiles/{uid}/private/auth | App Check, 10 req / 15 min / IP, 5-strike lockout |
| 2 | POST /auth/merchant/signin | merchantProfiles/{uid}/private/auth | same |
| 3 | Firebase signInWithEmailAndPassword | Firebase Auth legacy password | nothing we control |
Tier 3 is labelled "for migration period". Nothing ends it.
Axes โ
- Admin portal credential: none / set / set with
adminPasswordResetRequired - Merchant portal credential: none / set (for the same email)
- Legacy Firebase password: exists / absent
- Role claim:
admin/merchant/ neither - Ban state: not banned / actively banned
- App Check: valid token / missing or invalid (headless agent, script, reCAPTCHA failure)
- Password typed: matches portal credential / matches legacy only / matches neither
- Lockout: under five strikes / locked
Struck cells, with reasons โ
| Struck | Why |
|---|---|
| admin credential set AND merchant credential set, same email | Roles are exclusive in the claim; tier 1 returns 401 for a non-admin and tier 2 for a non-merchant, so only one tier can ever answer 200. One cell, not two. |
| role = neither, any credential state | Both portal tiers return the generic 401, tier 3 rejects or admits a non-portal user who then hits AccessDenied. No portal authority is granted. Out of scope of both issues. |
| lockout = locked, credential = none | The lockout gate sits after readCredential, so a credential-less account never claims a strike and can never be locked. Unreachable by construction. |
| App Check invalid, tier 3 | Tier 3 is client-side Firebase Auth and carries no App Check at all. There is no cell to decide; that IS the C13 finding. |
| banned + legacy absent | Nothing to sign in with on any tier. Refusal is not in question. |
Surviving cells and decisions โ
Read "today" as the shipped behaviour, "decided" as what this branch makes it do.
| # | Cell | Today | Decided | Mechanism / guard |
|---|---|---|---|---|
| C1 | admin, credential set, correct portal password, not banned, App Check valid | tier 1 gives 200 | unchanged | none |
| C2 | admin, credential set, wrong password, legacy password exists and matches what was typed | tier 1 401, tier 2 401, tier 3 admits | refused, "Incorrect email or password." | G1: tier 3 runs only after an explicit *_PASSWORD_NOT_SET (428). A 401 anywhere ends the ladder. |
| C3 | admin, credential set, wrong password everywhere | tier 1 401, tier 2 401, tier 3 rejects | unchanged outcome, one fewer round trip | G1 |
| C4 | admin, credential set, five wrong portal passwords, legacy still valid | tier 1 429 locked, falls through, tier 3 admits | refused; the lockout holds | G1 plus G2: a 429 ends the ladder explicitly. |
| C5 | admin, adminPasswordResetRequired: true, legacy password valid | tier 1 428 RESET_REQUIRED, client maps every 428 to NOT_SET, tier 3 admits | refused, reset message shown | G3: read error.details.error, distinguish ADMIN_PASSWORD_RESET_REQUIRED from ADMIN_PASSWORD_NOT_SET. The existing RESET_REQUIRED branch is dead code, the server never sends that string. |
| C6 | admin, credential none, legacy valid, not banned | tier 1 428, tier 3 admits | unchanged, deliberately: this is the open migration window and closing it is the operator's call (see Open decision below) | G4 stamps the event so the window becomes measurable. |
| C7 | admin, credential none, banned, legacy valid | tier 1 428 (the ban check never runs, it sits after verifyPassword), tier 3 admits, full session | refused with the generic 401 | G5: check the ban before the no-credential return, and answer 401 INVALID_CREDENTIALS, not 403. A pre-authentication 403 would be a ban oracle for any unauthenticated caller; 401 is the same answer an unknown email gets, so it leaks nothing new. |
| C8 | admin, credential set, correct password, banned | tier 1 verifies, then 403 ACCOUNT_BANNED, audited | unchanged | Already correct and already tested. G5 must not disturb it: possession was proven, so naming the ban is not an oracle. |
| C9 | merchant, merchant credential set, correct password | tier 1 401 (not an admin), tier 2 200 | unchanged | The tier 1 to tier 2 hop on 401 is the role disambiguation and must survive G1. |
| C10 | merchant, merchant credential none, legacy valid | tier 1 401, tier 2 428, tier 3 admits | unchanged, mirrors C6 | G4 stamps the merchant side too. |
| C11 | merchant, credential set, wrong password, legacy valid | tier 1 401, tier 2 401, tier 3 admits | refused | G1, symmetric with C2. |
| C12 | merchant, credential none, banned, legacy valid | tier 2 428, tier 3 admits | refused, generic 401 | G5, symmetric with C7. |
| C13 | App Check missing or invalid, any credential state | tier 1 401 APP_CHECK_REQUIRED, client maps 401 to "incorrect password", tier 2 401, tier 3 admits | refused, "This browser could not be verified." | G6: App Check failures are their own outcome, never a credential outcome. Today the App Check gate on tiers 1 and 2 buys nothing, because failing it routes the caller to the one tier that has no App Check. |
| C13b | rate limited by the per-IP cap on the signin route | 429, client shows the LOCKOUT message ("too many failed sign-in attempts"), which is a different thing | refused with its own message | G8, found by running C4 live on 2026-08-20: both a rate limit and a lockout answer 429 and were conflated, so a rate-limited admin was told they had got their password wrong five times and sent toward a reset they did not need. Distinguishable by RATE_LIMITED vs ACCOUNT_LOCKED in the body. Both stay terminal for the ladder. |
| C14 | admin, credential none, legacy absent (invited, never set anything) | tier 1 428, tier 3, auth/invalid-credential, "Incorrect email or passphrase." | unchanged | Deliberate. A precise message here ("no portal password, use Forgot password") is an enumeration oracle. Generic wins; the reset flow is the documented door. |
| C15 | Agent Probe: admin, credential none, adminPasswordResetRequired: true, headless, no App Check debug token | tier 1 401 App Check, tier 2 401, tier 3 admits, and every agent "admin sign-in" test to date has silently tested tier 3 | credential set through the real reset flow, so tier 1 answers; debug token injected so App Check passes | G7 (#904). After this, C5 and C13 stop applying to the probe and tier 1 becomes exercisable headlessly for the first time. |
Assumptions checked, per the skill's step 4 โ
- "Tier 3 grants less than tier 1." False. Legacy sign-in produces a Firebase session whose ID token carries the same
role: admincustom claim, sorequireAdminadmits it on every admin route. The tiers are equal in authority; only the credential differs. This is why C2, C4, C5, C7 matter. - "
requireAdmincatches the banned case anyway." Partly. It does block admin API routes (#193 fixed that). It does not stop the session existing, and it does not govern client-side Firestore reads. The route-level ban check was written as belt and braces to stop the token existing at all; C7 defeats exactly that half. - "The migration banner will prompt password-less admins eventually." False for the population that matters.
AdminMigrationBannerrenders only whenhasLanternAccountis true (users/{uid}.saltexists). An admin with no Lantern account is never prompted, ever. It is also dismissable per session, and it claims the password exists when/auth/admin/statusfails. It is not an enforcement path and cannot be treated as one. - "The
RESET_REQUIREDclient branch handles C5." False, twice over: the server sendsADMIN_PASSWORD_RESET_REQUIRED, and thestatus === 428branch returns before that check is reached. Dead code, verified by reading both sides. - "The App Check debug token will not work on the admin portal." False.
apps/admininitialises Firebase from the sameVITE_FIREBASE_APP_IDasapps/web, and the SDK readsself.FIREBASE_APPCHECK_DEBUG_TOKENbefore init regardless of theimport.meta.env.DEVguard infirebase.js, so a PlaywrightaddInitScriptsets it on the deployed build too. - "There is no test coverage for this." Half true, and the split is the finding.
adminSignin.integration.test.jscovers the route well (bans, lockout, strikes).apps/admin/src/__tests__/App.test.jsxcovers the role fork and nothing ofhandleSignInWithEmail. Every cell above lives in the untested half.
Invariants โ
Checked against every surviving cell.
| # | Invariant | Cells that break it today |
|---|---|---|
| I1 | A portal password, once set, is the credential of record. No other credential signs that account in. | C2, C4, C11 |
| I2 | An account the server refuses on one tier is never admitted by a later tier for the same reason. | C2, C4, C5, C13 |
| I3 | A banned account gets no admin session by any path. | C7, C12 |
| I4 | Failing browser attestation never widens what a caller may do. | C13 |
| I5 | A sign-in refusal reveals nothing an unauthenticated caller did not already know. | Upheld today; G5 must not break it (hence 401, not 403, in C7). |
| I6 | Every tier-3 sign-in is attributable after the fact. | C6, C10 (nothing records it) |
| I7 | Admin portal auth and app auth stay separate systems. | Upheld. Nothing here touches the phone/PIN path. |
Guards to build โ
- G1, fall through on the not-set signal only. Tier 3 runs when, and only when, a tier returned 428
ADMIN_PASSWORD_NOT_SETorMERCHANT_PASSWORD_NOT_SET. Any 401, 403, 429, or transport failure ends the ladder. Keeps the tier 1 to tier 2 hop on 401 for role disambiguation (C9). - G2, 429 is terminal. Surface the lockout, never fall past it.
- G3, distinguish reset-required from not-set on the client, against
error.details.error, both portals. - G4, audit the legacy tier. Fire-and-forget an
adminActionsrow on the 428 not-set return (success: false,reason: ADMIN_PASSWORD_NOT_SET), and stamptier: 'adminPassword'on the success row. Server-side and trustworthy: after G1, a 428 is the only door to tier 3, so counting them answers "who is still on the legacy tier" without trusting the client. Answers #924 point 2. - G5, ban check before the no-credential return, answering the generic 401. Leaves C8 exactly as it is.
- G6, App Check failures are their own outcome, with their own message, never a fallthrough.
- G7, set the Agent Probe credential through the product's own reset flow (request reset, read the token from Firestore with the Admin SDK on dev, POST the new password), never a direct
writeCredential. Dev only. Answers #904 and unblocks #924 point 3. - G8, tell a rate limit apart from a lockout. Same 429, different cause, different advice for the reader, terminal either way. Added after the live run; see C13b.
Every guard is strictly a refusal that does not exist today, except G4 (an audit write), G7 (dev fixture), and G8 (a message change). No cell that signs in successfully today stops signing in, except the ones the matrix names as defects.
Round two: what the review found, and what changed โ
A /code-review sweep at max effort on PR #935 returned 15 findings plus 4 addenda. The matrix missed real cells, and the biggest miss was a lockout.
C16, the cell that mattered: an admin whose role lives only in users/{uid}.role.requireAdmin and the portal's own checkAdminRole both grant admin from that field alone, documented there as legacy support during migration (#886). The signin route was stricter and answered 401, which was invisible while the client fell through on any 401, and became a full lockout once a 401 was terminal. A dev census on 2026-08-20 found one real account in exactly that state (users doc admin, claim user, no adminProfiles doc, no portal credential) that signs in today and would not have afterwards. The route now accepts either source, and a missing adminProfiles doc is treated as "no credential" rather than "not an admin". Guard G9.
This is the failure the matrix was supposed to prevent and did not. The axis "admin portal credential" was enumerated; the axis "HOW the server decides you are an admin" was not, so every cell silently assumed the claim path.
C17, the permissive default in the classifier. Three branches matched on status and treated an unrecognised body as that status's common case: an unlabelled 428 resolved to PASSWORD_NOT_SET and opened the legacy door, an unlabelled 429 claimed five failed attempts, an unlabelled 401 read as a wrong password. Reachable without any server change, because authApi only sets .details when the body parses. Every continue now requires the server's exact code; unrecognised is UNAVAILABLE and terminal. Guard G10.
C18, the legacy tier's own errors. That exit had no catch, so it broke the module's promise that every rejection carries a code, and auth/user-disabled announced a ban to a caller who only needed a 428 to get there. Guard G11.
Decided differently after the review โ
C5 is only half fixed, and the other half cannot be fixed here.
adminPasswordResetRequiredis written only when no credential exists, so the 428RESET_REQUIREDthis branch handles needs reset-required PLUS a credential, a state the product does not produce. The state that actually occurs returns 428NOT_SETand still reaches legacy. A census found 4 credential-less dev admins carrying the flag, so moving the check earlier would lock all four out. The reachable form of C5 closes only when the migration window closes, which folds it into the operator's decision below rather than into this branch.I5 does not hold, and the matrix claimed it did. G5 answers 401 for a banned credential-less admin and 428 for an unbanned one, which is a ban oracle for anyone who knows an email belongs to a credential-less admin. The test compared against an unknown email, which was never the leak. The tradeoff is taken deliberately:
GET /auth/admin/statusalready disclosesisAdminandpasswordSetunauthenticated to that same population behind the same App Check, so the marginal disclosure is "banned or not" while the closure is "a banned admin gets no session". Recorded as a decided tradeoff, not as a satisfied invariant, and flagged for the operator.
Known and NOT fixed here โ
- The whole ladder is client-side, so the legacy password still buys a full admin session.
VITE_FIREBASE_API_KEYships in the bundle, and a direct Identity Toolkit POST yields arole:'admin'token without running any of this code.firestore.rulesreads onlyrequest.auth.token.role, and theadminAuth: trueclaim the portal mints is never read anywhere. This branch narrows the PORTAL path; it does not reduce what the legacy credential can do. Closing that needs a rules change plus a claim check, and rules changes are operator-gated. - Admin onboarding still mints legacy-only admins. The invite flow sends a Firebase
generatePasswordResetLink, so a new admin gets a legacy password, no portal credential, andadminPasswordResetRequired: true. The merchant twin uses the portal-token flow instead. This is why 8 of 11 dev admins have no portal credential, and it means the legacy population grows with every new admin rather than shrinking. - The audit row counts attempts at the legacy door, not sign-ins, and an unauthenticated caller can drive it. The honest measurement for "who is still on the legacy tier" is a direct query over
adminProfilesfor a missingprivate/auth, which is exact and not attacker-drivable. On dev today that is 8 of 11.
Round three: who counts as an admin (#886) โ
Same subsystem, one layer down. The tiers above decide WHICH CREDENTIAL signs you in; this decides whether the resulting session is an admin at all. It belongs beside them because a demotion is a state transition across the same surface, and because the round-two lockout cell turned out to be an instance of it.
C19, the live one. Dev account 4eLDbNWJ: created as an admin 2026-01-28, Auth claim now user, users/{uid}.role still admin, zero adminActions rows, and no roleUpdatedAt or roleUpdatedBy on its users doc. Every step of that is the #886 signature and the code confirms it: admin creation always sets the claim first (adminUsers.js:596), and demotion sets the claim, then the doc, then the audit row (roles.js:101). A failure between the first and second write leaves exactly this state, with no audit trail, permanently. It has not signed in since 2026-01-30, which is why seven months passed without anyone noticing.
Its disposition is not decided here. Whether that account is finished, restored, or investigated is an operator call. The fix below denies it going forward, which is the whole of what this branch does about it. It was not touched.
Axes โ
- Claim state: absent /
admin/ present-but-not-admin - users doc state: missing /
role: admin/role: something else - Enforcement point: admin API (
requireAdmin), assistant API, merchants API, the portal sign-in route, the portal client (checkAdminRole),firestore.rules - How the state arose: created as admin / promoted / demoted cleanly / demoted with only the claim written / never migrated to claims
Axis 4 is diagnostic rather than decisive: the code cannot see history, only the two current values. It matters because it says which cells are REACHABLE, and the answer is that the dangerous one is not only reachable but currently occupied.
The full cross product, claim by users doc โ
Nine cells, all of them, because at this size there is no excuse for sampling.
| Claim | users doc | Today | Decided | Why |
|---|---|---|---|---|
admin | admin | admin | admin | The normal case. |
admin | not admin | admin | admin | A promotion whose claim landed and whose doc write did not. Granting matches the intent of the operation that was in flight. |
admin | missing | admin | admin | Claim is authoritative and it says admin. |
| absent | admin | admin | admin | The genuine pre-claims-migration admin. This is the cell that makes deleting the fallback (issue option 3) unsafe: dev data cannot prove no such account exists on prod. |
| absent | not admin | deny | deny | Nothing says admin. |
| absent | missing | deny | deny | Nothing says anything. |
| not admin | admin | ADMIN | DENY | The hole, and C19 occupies it. A present non-admin claim is a deliberate statement by whoever wrote it; treating it as merely inconclusive is what makes a half-demotion fail open. |
| not admin | not admin | deny | deny | Both agree. |
| not admin | missing | deny | deny | Claim is authoritative and it says no. |
Exactly one cell changes, from grant to deny, and it is the one where the two sources disagree in the dangerous direction. Every other cell keeps today's answer, including the pre-migration cell the fallback exists for.
The rule, in one line โ
A present claim is authoritative. The users doc is consulted only when the claim is absent.
That is option 1 from the issue. Option 3 (delete the fallback) is rejected: it also denies the absent-claim cell, and there is no evidence from dev that prod has no such account. Option 2 (atomic demotion) is worth doing and does not replace this: it narrows how the state ARISES while this closes how it is READ, and the state already exists, so a write-side fix alone leaves C19 exactly as it is.
Where the rule has to hold โ
The either-or pattern is not one site.
The count below is WRONG and is left standing with its correction. It said six, and a scoped review on 2026-08-20 found at least nine more admin-role decisions. The reason matters more than the number: the site list was built by grepping for
claims.roleandcustomClaims, which finds sites that read the CLAIM. Three services (venues,analytics,docs) gate admin onusers/{uid}.roleALONE through an identicalrbac.jsand never mention claims, so they were structurally invisible to that search. Also missed: three claim-only siblings insideadminAuth.jsitself (password-set, password-reset,/status), a doc-only early-exit branch insidecheckAdminRole, an unconverted twin inapps/web/src/lib/auth.js,checkMerchantRole, andadminClaim.js, which MINTS a claim from the either-or.A search shaped by the pattern you already found cannot find the sites that do not contain it. Enumerate by ASKING THE QUESTION ("what decides admin here") across every service, not by matching the syntax of the instance in hand.
Six were converted, and a rule applied to six of fifteen is not a rule.
| Site | Surface | In scope |
|---|---|---|
services/api/auth/src/middleware/auth.js requireAdmin | admin API | yes |
services/api/auth/src/routes/adminAuth.js signin | portal sign-in | yes (narrows round two's G9) |
services/api/assistant/src/middleware/auth.js | assistant API | yes |
services/api/merchants/src/middleware/auth.js (two admin arms) | merchants API | yes, admin arms only |
apps/admin/src/firebase.js checkAdminRole | portal client | yes, as defence in depth ONLY |
firestore.rules isAdmin() | client Firestore reads | no, tracked as #939, operator-gated |
Two constraints held deliberately:
requireMerchantkeeps today's semantics. It has the same either-or shape and it is not the same decision. The helper is available to it and is not applied; changing it is a separate conversation.checkAdminRoleruns in the browser, so it is defence in depth and never the enforcement point. It gets the same rule so the UI does not disagree with the server, not because anything depends on it being right.
Invariants for this round โ
| # | Invariant | Cell that breaks it today |
|---|---|---|
| I8 | Two sources disagreeing never resolves to MORE access than either alone would grant. | the not-admin/admin cell |
| I9 | A demotion that reports success has actually removed access, or has not reported success. | C19: the audit row is absent, but so is any signal, so the operator sees neither |
| I10 | Every enforcement point answers the same question the same way. | six sites, one of which (firestore.rules) cannot be brought into line on this branch |
I10 is only PARTLY satisfied at the end of this branch.
This paragraph said the opposite when it was written, and the wrong version is worth leaving visible. It claimed "a half-demoted account is denied by every API and still passes rules for direct client reads". That is backwards. A half-demoted account's claim reads
user, sorequest.auth.token.role == 'admin'is false and the rules deny it too: that cell is CONSISTENT. The error propagated into a commit message and into the scoping of #939 before a review caught it on 2026-08-20.
The cell that actually diverges is the opposite one: claim ABSENT plus a users doc saying admin. This branch deliberately keeps GRANTING it at all five services and the portal client, while every isAdmin() in firestore.rules and storage.rules DENIES it, because there is no role claim in the token to match.
That makes the preserved legacy admin worse off than the matrix first described. It is granted by the APIs, denied by rules for every direct client read, and it cannot complete its migration: POST /auth/admin/password gates on the claim and answers 403, and /password/reset gates on the same claim and returns the safe response without sending mail. The 428 in that cell is a door that opens onto a wall, and the test named "an admin with NO claim at all reaches the migration door" asserts the 428 and never asked what happens next.
#939 must be scoped at the claim-absent cell, not the half-demoted one.
Guard โ
G12: one shared predicate, six call sites. hasAdminRole({ claims, userDoc }) in @lantern/shared/auth, SDK-free so the browser bundle can import it. Putting the rule in one place is what makes the diff smaller rather than larger, and it means the next enforcement point that gets added inherits the decision instead of re-litigating it.
WITHDRAWN 2026-08-20: the code for this round was reverted โ
The two commits implementing the rule (c5a0d317, 3f6bdb19) were reverted on this branch the same day, before merge. The analysis above stays, corrections and all, because it is the record of what was attempted and why it was pulled, and because the enumeration is the starting point for doing it properly.
Why it was pulled. The claim the change rested on, "exactly one cell changes", was true of the PREDICATE and false of the SYSTEM. A predicate-level enumeration cannot answer a system-level question, and the distance between those two is exactly where a half-applied authorization narrowing lives. Half-applied is worse than not applied: it makes one account an admin at three services and not at four, which nobody can reason about and no test suite is shaped to catch.
Two disqualifying findings, both verified first-hand:
- The site census missed at least nine decisions, for the reason recorded above: the search was shaped by the pattern already found.
- The preserved claim-absent population is trapped, not preserved. Its migration door is locked from the other side (
POST /auth/admin/passwordgates on the claim and answers 403;/password/resetgates on the same claim and silently sends nothing), and every rulesisAdmin()denies it. Asserting the 428 and never asking what the account could do NEXT is the same shape as an absence test that passes for the wrong reason.
A third was introduced by the narrowing itself: the attach-to-merchant path in adminUsers.js becomes a one-way admin strip for exactly that population, because its guard is claim-only and it then writes a merchant claim.
What the next attempt needs. A census built by READING every service's middleware rather than grepping (nine-plus sites, not six); the repair and migration paths (/password, /password/reset, adminClaim.js, the UserDetailPanel claims-sync UI, roles.js's isDemotion) decided in the same pass as the gates, since a rule with no way back is a trap; and the firestore.rules question (#939) settled alongside rather than deferred, because the divergent cell is created by the fallback this rule preserves. It belongs with the onboarding fix: same surface, same population.
Round four (2026-08-21): the census done by READING โ
Yesterday's attempt was pulled because its site list was built by grepping claims.role and customClaims, which can only find sites that read the CLAIM. This list was built by walking services/api/*/src/middleware/ and every route that decides "is this an admin", then reading each one.
Six sites last time. Nineteen on the services, plus four on the client. And they are not one shape, they are three, which is the finding that changes the whole approach.
The three shapes โ
| Shape | Reads | What it gets wrong |
|---|---|---|
| A. Either-or | claim OR doc | The #886 hole. A half-demoted account (non-admin claim, stale doc) is re-granted. |
| B. Doc-only | doc, never the claim | The SAME hole in a different costume, and the one a claims-shaped search cannot see. A half-demoted account passes because nothing ever looks at the claim that demoted it. |
| C. Claim-only | claim, never the doc | The OPPOSITE problem. Excludes the claim-absent legacy admin that the fallback deliberately preserves. This is what makes that population trapped. |
A fix that only converts shape A leaves the hole open at every shape B site and leaves the trap fully intact. That is the "half-applied is worse than none" case, and it is why the count matters.
Services โ
| Site | Shape | Reads |
|---|---|---|
auth middleware/auth.js requireAdmin | A | claim, else users doc |
auth middleware/auth.js requireMerchant | A | merchant claim, else doc. Out of scope, different decision |
auth adminAuth.js POST /signin | A | claim, else users doc |
auth adminAuth.js POST /password | C | claim only, 403. The trap's front door |
auth adminAuth.js POST /password/reset | C | claim only, silently sends no mail |
auth adminAuth.js GET /status | C | claim only; contradicts /signin for the same account |
auth adminClaim.js:79 | A | claim OR doc, and then MINTS a claim from it |
auth adminUsers.js:577 already-admin guard | C | claim only, so a doc-only admin can be "promoted" again |
auth adminUsers.js:805 delete guard | A | claim OR doc, deliberately, with a comment saying why |
auth adminUsers.js:890 setup-link guard | C | claim only |
auth adminUsers.js:1405 attach-to-merchant guard | C | claim only, then writes a merchant claim |
auth roles.js:68 isDemotion | C | claim only, so repairing a half-demotion skips the cascade |
assistant middleware/auth.js requireAdmin | A | claim, else users doc. Also has no ban check at all |
merchants middleware/auth.js requireAdminAccess | A | claim, else users doc |
merchants middleware/auth.js requireMerchantAccess admin arm | A | claim, else users doc |
venues middleware/rbac.js requireRole | B | users doc only |
analytics middleware/rbac.js requireRole | B | users doc only |
docs middleware/rbac.js requireRole | B | users doc only |
lanterns | none | no admin gate exists, and none is needed. Checked rather than assumed: every mounted route (/lanterns, /lanterns/schedule, /lanterns/bonfire) is user-scoped behind verifyAppCheck plus verifyFirebaseToken, and /cleanup is behind schedulerAuth. There is no admin-only surface, so this is correct by design, not an omission. The two conclusions look identical in a census that only reports "no gate found". |
analytics/src/routes/merchant.js:49 is an amplifier rather than a decision: it trusts req.user.role, which rbac.js sets from the DOC, to authorise a cross-merchant data override.
Client (apps/admin) โ
| Site | Shape | Reads |
|---|---|---|
firebase.js checkAdminRole, main path | A | claim, else users doc |
firebase.js checkAdminRole, invalid-user early exit | B | users doc only, inside the same function |
firebase.js checkMerchantRole | A | claim or doc, and grants on a doc role of admin |
shared/lib/merchantApi.js getCurrentRole | already correct | returns the claim IF PRESENT, else the doc |
getCurrentRole is worth naming: it already implements the exact rule this round is arguing for, so the rule is not novel to this codebase, just unevenly applied.
Out of scope and tracked: firestore.rules and storage.rules isAdmin() are both claim-only (shape C), which is #939.
What a claim-absent admin can actually DO โ
The question yesterday's attempt never asked, and the answer is the reason the migration window cannot close.
Such an account is GRANTED by every shape A and shape B site, so it signs in and the portal renders. It is REFUSED by every shape C site, which is precisely the set that would let it stop being claim-absent:
POST /auth/admin/passwordanswers 403, so it cannot set a portal password.POST /auth/admin/password/resetreturns the safe response and sends no mail, so it cannot start a reset either.GET /auth/admin/statusreportsisAdmin: false, so the portal's own UI disagrees with the sign-in route about whether this person is an admin.- Every
isAdmin()infirestore.rulesandstorage.rulesdenies it, so direct client reads fail throughout the shell.
Preserving that population is not preservation, it is a trap with a comment on it saying "kept deliberately". 8 of 11 dev admins have no portal credential; the subset of those that is also claim-absent has no door out at all.
The census, and how to re-derive it โ
The number the window decision turns on should not rest on anyone's word, including mine. This is exactly what was queried on lantern-app-dev, 2026-08-20.
Population. The union of users where role == 'admin' and every document in adminProfiles. The union rather than either alone, because the two disagree (#886) and taking one would undercount.
"Has a portal credential" means, for a uid, EITHER adminProfiles/{uid}/private/auth exists, OR adminProfiles/{uid}.adminPasswordHash is present. Both halves are needed: #875 moved the credential into the private subdoc and deletes the inline field, so checking only the subdoc misses nothing today but checking only the inline field would report every migrated admin as credential-less.
"Would be refused by the new ladder" means the Firebase Auth user exists, AND (its custom claim is not admin OR it has no adminProfiles doc), AND it has no portal credential. That is the C16 shape.
const users = await db.collection('users').where('role', '==', 'admin').get()
const profiles = await db.collection('adminProfiles').get()
const ids = new Set([...users.docs.map((d) => d.id), ...profiles.docs.map((d) => d.id)])
// per uid: claim from auth.getUser(uid).customClaims?.role
// hasCredential = private/auth exists || 'adminPasswordHash' in profileResults, 2026-08-20: 13 documents in the union, 11 of them real admin accounts (one has no Auth user, one has a merchant claim). 8 of the 11 have no portal credential. Of the credential-less ones, 4 carry adminPasswordResetRequired: true and 4 do not. One account matches the C16 refuse shape.
Read-only, field existence only, no credential values read. Re-running it reads real admin records, so re-derive it when the number matters for a decision rather than as a routine check.
Open decision for the operator โ
When does the migration window close? This branch deliberately leaves C6 and C10 admitting: an admin with genuinely no portal password still signs in on the legacy tier. Everything above narrows tier 3 to exactly that population and makes it countable (G4). Closing the window is a policy call with a real blast radius (any admin without a portal password is locked out the moment it flips) and it is not made here.
The recommendation is to ship the guards, let G4 measure for a short while, and decide from the count rather than from a guess.
Out of scope on this branch โ
- #886 (admin authorization has two sources of truth):
requireAdminaccepts either the claim or theusersdoc. Real, adjacent, and it will collide with anything written here. - #882 (the false password banner):
AdminMigrationBanneris analysed above as evidence, not touched. - firestore.rules: untouched. C7's client-side-read residue is noted, not fixed, because a rules change needs the operator's word.