T 025
uid: T-025 title: Migrate user-facing Firestore access to the client SDK (Admin SDK β derived/privileged only) status: doing area: auth-rbac created: 2026-06-13 updated: 2026-07-02 succeeds: T-022 absorbs: T-107
Absorbed T-107 (owner-confirmed 2026-07-02). The legacy-role retirement (drop
ROLE_PERMISSIONS/canPerform,hasAnyRoleout of the fourfirestore.*.rules+ page-rule role fields) is phase 4 of this task's plan β it's blocked on migrating the 11 livecanPerformaction-gate callers (phase 3), so it can't be a standalone task. T-107 isdropped/folded; its prerequisite (onboarding badges) already shipped in PR #835. RBAC sequence: succeeds T-022 β needs the strict Security Rules live before user-facing reads/writes move to the client SDK. See the sequence map in T-022. (Marked by EOP Local Assistance, 2026-06-23.)
Goal¶
Move the data paths a user touches within their own scope off server API routes + Admin SDK and onto the Firebase CLIENT SDK, gated by Security Rules + claims. Reserve the Admin SDK for: derived/computed data (journals, reports, trial balance), secrets/ integrations (OCBC, Airwallex, Drive, GCP, Telegram), privileged mutations (claim mints, role changes, cross-entity admin), and multi-doc invariants Rules can't enforce. The principle/taxonomy IS the work; the SDK choice falls out of it.
Notes¶
- Client SDK is already used in some components (StudentDialog) and
lib/firebase.tsexposes per-DB client handles β so the foundation exists; this is expansion, not bootstrap. - Depends on T-022 (strict Rules must gate before reads move client-side) and should respect T-021's migrated document schema.
- SA agent confirmed (2026-06-13): client SDK adoption is entirely mine, no IAM/timeline
coupling. Only risk: routing a NEW path through the legacy
GOOGLE_*fallback (Drive/BQ) would work today but break when service@ is deleted ~2026-07-12 β don't.
Log¶
- 2026-06-13 created (split out of T-022 per the SA-coordination answers).
- 2026-07-02 scoped by User Management (owner asked to "scope it first"). Scope + phasing
- the T-107 coupling below.
- 2026-07-02 phase 3 done (User Management): decision A applied (
auditing-accountant-erlgainsaccounting:config:configure@erl) + all 13 action-gate call sites migrated to the dual-gatecanPerformActionOrLegacybridge.tsc+ RBAC tests green. Phase 4 (legacy retirement) pending a live badge-coverage check. Branchclaude/audit-user-access-control-cch7t.
Scope (2026-07-02, User Management)¶
- β
Attestation (User Management): read
AGENTS.md; checked the board β this is the T-025 scope, cross-linked to T-107 (legacy-role retirement) which I found is coupled to this task, not independent. Not duplicating; extending T-025 with the plan. - Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
- Approved by: the owner (verbatim, 2026-07-02): "For T-025, scope it first".
Inventory β 64 API routes import the Admin SDK. Categorised:¶
KEEP on Admin SDK (the reserved cases β most routes). These are correct as-is; do NOT move:
- Integrations / secrets: ocbc/velocity, airwallex, gcp, telegram, profile/telegram,
workspace, drive/*. Third-party creds; can't run client-side.
- Derived / computed: accounting/* (journals, reports, reconciliation, trial balance, IR56M
schedule, transaction matching), records/* (XLSX exports, aggregations), invoices/quotations
generation. The value IS the server-side derivation.
- Privileged / cross-entity: admin/* (users, roles, migrations, bank-accounts, receipts),
auth/* (claim mints, profile bootstrap β writes to users/{uid} which the strict rules
deliberately block from the client), subsidiaries (reference-data writes), cron/* (no user
session).
- Multi-doc invariants Rules can't express: period-lock enforcement, tx match/unmatch (touches
the tx + the invoice + the journal atomically), WOPC/IR56M signing state machines.
MIGRATE candidates (user-facing reads within the caller's own subsidiary scope). Smaller set;
each needs a per-path check that the strict rules already cover the exact query:
- Coaching: student/session reads a MEL user does within their scope (some are already client-SDK
via StudentDialog β expand, don't bootstrap).
- Projects: project/invoice reads an EPL user does within their scope.
- The read side is the safe, high-value slice: the strict rules already gate tebs-mel / tebs-erl
reads by badge/subsidiary, so moving these client-side makes the RULES the real enforcement
(defense-in-depth) rather than belt-and-suspenders behind an Admin-SDK route.
- NOT invoice/document generation β that's derivation, stays server-side.
NEEDS-DECISION (per-route judgment during execution): coaching/payments, coaching/invoices/*
mix user-facing reads with derived writes; split read (client) from write/generate (server).
Phasing (lowest-risk first; each phase its own PR with a rule-coverage check)¶
- Read-only client-SDK expansion β move a first coaching/projects read path client-side, verified against the deployed strict rules. Proves the pattern; zero write risk.
- User-facing writes within scope β the writes the rules already permit for the user's badge/subsidiary (e.g. a coaching admin editing a MEL session), path by path.
- Server action-gate migration (the T-107 unlock) β migrate the 11 API routes + client
components that still call
canPerform(role,β¦)/roleHasPermission/ROLE_PERMISSIONSover tocanPerformAction(badges,β¦)(already built + tested in T-022 step 3). Only after this canROLE_PERMISSIONS/canPerformbe retired. - Terminal: finish T-107 β with the action gates on badges and the rules already badge-only
(see the T-107 note), delete
ROLE_PERMISSIONS,canPerform*,canPerformActionOrLegacy, and thehasAnyRolehalves from the fourfirestore.*.rules+ the page-rule role fields.
2026-07-02 β phase-1 reconnaissance RE-BASELINES the plan (User Management)¶
- β
Attestation (User Management): read
AGENTS.md; this is execution recon for T-025 phase 1. - Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
- Finding β the "migrate user-facing reads" premise is largely already satisfied or N/A:
- Coaching reads are ALREADY on the client SDK.
components/StudentDialog/*(Sessions, Payments, Retainers, Vouchers, Personal, RateModal, SessionDetailβ¦) andcomponents/coaching/CoachingSessionsApp.tsxall readtebs-meldirectly vialib/firebase(getDocs/onSnapshot). The 2026-07-02 Cloud Monitoring check (ZEROPERMISSION_DENIEDacross 616K requests since the 06-25 strict-rules flip) proves these client reads already pass the strict rules in prod. Phases 1-2 for coaching = done, empirically verified. - Projects/accounting "reads" are DERIVED, not raw β they stay server-side.
GET /api/projects(and the show route) don't do a within-scope raw read: they query multiple subsidiary DBs, run a single transactions scan to derive per-invoice payment status (enrichProjectInvoicesWithPaymentDataServer/buildPaymentMapFromTransactions), and runcheckInvoiceIntegrity. That's the taxonomy's KEEP-on-Admin-SDK "derived/computed" bucket β moving it client-side would be wrong (perf + cross-subsidiary + server-canonical derivation per T-075/T-076). - Consequence: the broad "read migration" (original phases 1-2) has no meaningful remaining surface β coaching is done, projects/accounting reads are derived. T-025's real remaining value collapses to the action-gate migration (old phase 3) + the T-107 retirement (old phase 4) β i.e., T-025 is now essentially "retire the legacy role model," which is the owner-desired outcome.
- Revised plan (supersedes the 4-phase list above):
- ~~Read migration~~ β DONE/N-A (coaching client-SDK already; projects/accounting reads are derived). No code; recorded here so nobody re-attempts it.
- Design the legacy-
Permissionβ badge-grant map. Each of the ~24Permissionstrings (projects:read,transactions:write,accounting:config, β¦) needs a canonical badge permission-string equivalent (projects:detail:read@<sub>, β¦). This is the one real design step; it needs owner-visible sign-off because it's the semantic bridge. - β
DONE (2026-07-02) β Migrated the action-gate callers (13 sites: 10 API routes + 3 client
components) to
canPerformActionOrLegacy(user, { badge, legacy }), subsidiary-scoped. See the "Phase 3 β action-gate migration COMPLETE" section below. - β³ NEXT (folded T-107): retire
ROLE_PERMISSIONS/canPerform*/canPerformActionOrLegacy; striphasAnyRolefrom the fourfirestore.*.rules+ the page-rule role fields. Gated on a live badge-coverage check first β no currently-authorized user may lose access when the legacy role path is removed. - Why this needs an owner checkpoint before code: step 2 (the Permissionβbadge map) is a semantic design decision, and step 3 changes prod server-side authorization on core routes (projects, accounting settings, coaching payments/invoices) β higher stakes than a read migration. Recommend the owner review the mapping table before I migrate callers.
Step 2 deliverable β legacy Permission β badge-grant map (2026-07-02, User Management)¶
8 distinct legacy permissions are gated at live canPerform call sites (grep of pages/api
+ components; the initial pass missed the two coaching routes' sessions:write). The rest of the
24-string Permission union is unused at runtime, so the migration only needs to map these 8.
Mapping (badge grant a caller checks via canPerformActionOrLegacy(user, { badge, legacy })), with
the starter badges that satisfy each grant (badge model only β see the super-admin note below):
Legacy Permission |
Live sites | Badge grant | Satisfied by (badges) |
|---|---|---|---|
projects:read |
4 | projects:detail:read@epl |
epl-administrator, director-erl |
projects:write |
5 | projects:detail:write@epl |
epl-administrator, director-erl |
projects:delete |
3 | projects:detail:delete@epl |
epl-administrator, director-erl |
invoices:read |
1 | projects:invoice:read@epl |
epl-administrator, director-erl, bookkeeper-epl |
invoices:write |
2 | projects:invoice:write@epl |
epl-administrator, director-erl |
invoices:delete |
2 | projects:invoice:delete@epl |
epl-administrator, director-erl |
sessions:write |
2 | coaching:sessions:write@mel |
mel-administrator, director-erl |
accounting:config |
1 | accounting:config:configure@erl |
auditing-accountant-erl (decision A), director-erl |
Note on super-admin: the super-admin badge deliberately grants only the RBAC/meta surfaces
(rbac, users, audit-log, settings, tools) β it does not grant projects/coaching/
accounting operational access. Today a super_admin user still passes every gate above via the legacy
role path (ROLE_PERMISSIONS.super_admin has all of these). That path is preserved through the
migration window by canPerformActionOrLegacy; phase 4 removes it, at which point the owner must
hold director-erl (+ super-admin) as a badge union to keep operational access. This is the
phase-4 coverage gate.
β
Decision β accounting:config β auditing-accountant-erl: (A), applied 2026-07-02. The owner
chose (A). accounting:config:configure@erl was added to the auditing-accountant-erl badge in
lib/rbac/badges.ts, preserving the auditing accountant's statutory fiscal-calendar edit authority
(parity with the legacy auditing_accountant role, which held accounting:config). A regression test
pins it in __tests__/lib/rbac/badges.test.ts (auditor β / director β / bookkeeper β / super-admin
badge β).
Phase 3 β action-gate migration COMPLETE (2026-07-02, User Management)¶
- β
Attestation (User Management): read
AGENTS.md; this is T-025 phase-3 execution. - Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
- Branch:
claude/audit-user-access-control-cch7t(restarted fromorigin/mainβ the branch's prior PR #420 was squash-merged 2026-05-18, so its old commits were already-merged history). - Migrated all 13 live action-gate call sites (10 API route files + 3 client components) from
canPerform(role, status, legacy)βcanPerformActionOrLegacy(user, { badge, legacy }): badge path first, legacyrolefallback preserved for the window. Purely additive / zero lockout risk β badge-equipped users pass via badge, role-only users still pass via legacy, andRBAC_ENABLED=falsestill bypasses through the legacy path (the badge path ignores the flag, the legacy path honours it). - Files:
accounting/settings.ts,coaching/payments/[id].ts,coaching/invoices/[sessionId]/[documentId].ts,invoices/[year]/β¦/timeline.ts,projects/[year]/[projectId].ts,projects/[year]/index.ts,projects/by-id/[projectId].ts,projects/by-id/[projectId]/invoices/index.ts,projects/by-id/[projectId]/quotations/index.ts,projects/index.ts; componentsFiscalYearConfig.tsx,ProjectsApp.tsx,ProjectShowApp.tsx. DeadUserRole/UserStatusimports dropped from the two coaching routes. - Verified:
tsc --noEmitclean;vitest run __tests__/lib/rbac/95/95 green;eslintdelta 0 (139 pre-existing errors before == after; build unaffected βnext.config.tshaseslint.ignoreDuringBuilds). - Phase 4 is deliberately NOT in this PR. Removing the legacy fallback + stripping
hasAnyRolefrom the fourfirestore.*.rulesis the real cutover and is gated on a badge-coverage check of every currently-authorized user (esp. that the owner and any super_admin/admin-role users hold the operational badges they'll need once the role path is gone β see the super-admin note). Run that verification against live data first, then land phase 4 as its own PR.
Phase-4 gate β LIVE badge-coverage check (2026-07-02, User Management)¶
Ran read-only against prod (aote-system via Firestore REST; matcher + registry imported from
lib/rbac itself so the check can't drift; overlay merged β currently 0 overlay docs). Checked every
user's badges against the badge equivalents of the 8 live-gated legacy permissions their role grants.
Result: 5 users Β· 4 active Β· 1 fully covered Β· 3 with gaps (report archived in session scratchpad):
| User | Legacy role | Badges | Gaps at phase 4 |
|---|---|---|---|
| jake@establishrecords.com | admin | super-admin + director-erl + 5 more | β none |
| me@jefferochan.com | super_admin | super-admin only | β all 8 (no operational badge) |
| alisonhytang@gmail.com | admin | super-admin only | β all 8 (no operational badge) |
| waion1985@gmail.com | auditing_accountant | auditing-accountant-erl + both bookkeepers | β projects:read only |
| jeffero.c@gmail.com | auditing_accountant | (suspended β skipped; fails all gates in both models) | β |
Remediations required BEFORE phase 4 (all additive / zero-risk during the dual-gate window):
1. Assign director-erl to me@jefferochan.com β matches T-022's documented intent ("the owner is
director-erl + super-admin"). Via Edit User β Badges, or a targeted script run.
2. Assign director-erl (or an owner-chosen narrower set) to alisonhytang@gmail.com β
director-erl + super-admin preserves her legacy-admin parity; narrower = a deliberate
right-sizing decision for the owner.
3. Registry edit (decision-A precedent): add projects:detail:read@epl + projects:invoice:read@epl
to auditing-accountant-erl β the legacy auditing_accountant role has projects:read +
invoices:read; the badge carries neither (waion's invoices:read is only covered incidentally
via bookkeeper-epl). "The auditor reads everything" says the badge itself should carry the reads.
Fixes waion + any future auditor. (Observation, no live user affected: legacy bookkeeper role
also had projects:read/invoices:read; bookkeeper-mel covers neither and bookkeeper-epl only
invoices β revisit if a bookkeeper-role user ever exists, or resolve naturally in T-151's
re-sort.)
Phase 4 is GO once 1β3 land (1β2 are data edits, 3 is a small PR + tests).
β Remediations EXECUTED + re-verified (2026-07-03, owner-approved "Agree with the 3 fixes")¶
- β
director-erladded to me@jefferochan.com β Firestore profile AND Firebase Auth custom claims (read-merge-write, never replaced blind). Idempotent one-off script, session scratchpad. - β Same for alisonhytang@gmail.com (owner chose parity over narrowing).
- β
auditing-accountant-erlbadge now carriesprojects:detail:read@epl+projects:invoice:read@epl(lib/rbac/badges.ts) with a regression test (reads β / writes β). Checked the tebs-epl rules first: projects there are gated by the admin catch-all (no granular badge list), so no rules edit needed β the auditor's projects/invoices reads are enforced at the API gates, which is exactly what the badge grant fixes. - Re-check (live, same harness):
RESULT: ALL ACTIVE USERS COVERED β phase 4 GOβ waion β (via the registry fix), jake β, alison β + me β (both nowsuper-admin + director-erl), suspended user skipped (fails all gates in both models). - β Sequencing note: the registry fix is code β it must be deployed with (or before) the phase-4 cutover; until then waion passes via the legacy path as today. Claims for the two director-erl grants are already live server-side.
- Phase 4 status: GO. Per T-151 sequencing, execute as the coordinated gate cutover
(drop legacy fallback + strip
hasAnyRole, with T-150 phase 4's merged-registry resolution + hide-until-confirmed client).
β Phase 4 EXECUTED (2026-07-03, owner: "proceed", commits per stage)¶
- Stage 1 (app,
0cd266ca): action gates badge-only.canPerformActionMerged(async, code βͺ overlay, 60s cache, fail-open,!RBAC_ENABLEDrescue valve kept) now gates all 10 API routes; the 3 client components pairuseBadges()+canPerformActionInunder hide-until-confirmed;canPerformActionOrLegacyDELETED. vitest 576/576, tsc clean. - Stage 2 (rules,
97390ecb): all capability functions across the 4 DBs drop their|| hasAnyRole(β¦)half after a rules-level parity check against live badge claims (1 gap found + fixed: tebs-erlcanReadProjectsgainsauditing-accountant-erl, mirroring fix 3). Badge helpers now requireisActive()β the role path's status check is carried over, closing the pre-existing hole where a suspended user's badge claims passed client reads (claims verified active for all 4 live users first). Auto-deployed bydeploy-firestore-rules.ymlon push (its designed behavior). - Deliberately REMAINING legacy surface (absorbed into T-151, which retires the role concept
wholesale):
isAdmin()role backstops on rules admin catch-alls (all admin-role users also hold super-admin); page-ruleallowedRoles/requiredPermissionslegacy fields +evaluatePageRulefallback (badge gate already short-circuits for badge holders);ROLE_PERMISSIONSconsumers in the approvals/restricted-actions subsystem, the legacy Permissions-tab matrix (display-only, collapsed), custom-roles UI, and profile display. None of these grant the 8 cut-over actions anymore.
Hard constraints¶
- Every migrated path MUST be provably covered by the deployed strict rules first (a client read
with no matching
allow= a broken page). Verify per-path againstfirestore.*.rules. - Do NOT route any new server path through the legacy
GOOGLE_*fallback (Drive/BQ) βservice@was slated for deletion ~2026-07-12; usefirebase-adminsdk-fbsvc@only. - The badge claims are now the enforcement substrate (T-022 live since 06-25, universal coverage verified 07-02) β safe to lean on rules.
T-107 coupling (found 2026-07-02) β the key sequencing fact¶
T-107 ("drop the legacy role half") is NOT fully independent of this task. Its data-layer + page-gate
removal is shippable now (badge coverage verified, zero prod PERMISSION_DENIED), but retiring
ROLE_PERMISSIONS / canPerform is blocked on migrating the 11 live action-gate callers β which is
Phase 3 above. So the clean end-state is: T-025 phases 1-3 migrate the action layer, then T-107's
terminal removal lands as phase 4. Recorded in both files.