Skip to content

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, hasAnyRole out of the four firestore.*.rules + page-rule role fields) is phase 4 of this task's plan β€” it's blocked on migrating the 11 live canPerform action-gate callers (phase 3), so it can't be a standalone task. T-107 is dropped/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.ts exposes 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-erl gains accounting:config:configure@erl) + all 13 action-gate call sites migrated to the dual-gate canPerformActionOrLegacy bridge. tsc + RBAC tests green. Phase 4 (legacy retirement) pending a live badge-coverage check. Branch claude/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)

  1. 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.
  2. 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.
  3. Server action-gate migration (the T-107 unlock) β€” migrate the 11 API routes + client components that still call canPerform(role,…) / roleHasPermission / ROLE_PERMISSIONS over to canPerformAction(badges,…) (already built + tested in T-022 step 3). Only after this can ROLE_PERMISSIONS / canPerform be retired.
  4. 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 the hasAnyRole halves from the four firestore.*.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…) and components/coaching/CoachingSessionsApp.tsx all read tebs-mel directly via lib/firebase (getDocs/onSnapshot). The 2026-07-02 Cloud Monitoring check (ZERO PERMISSION_DENIED across 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 run checkInvoiceIntegrity. 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 ~24 Permission strings (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; strip hasAnyRole from the four firestore.*.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 from origin/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, legacy role fallback preserved for the window. Purely additive / zero lockout risk β€” badge-equipped users pass via badge, role-only users still pass via legacy, and RBAC_ENABLED=false still 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; components FiscalYearConfig.tsx, ProjectsApp.tsx, ProjectShowApp.tsx. Dead UserRole/UserStatus imports dropped from the two coaching routes.
  • Verified: tsc --noEmit clean; vitest run __tests__/lib/rbac/ 95/95 green; eslint delta 0 (139 pre-existing errors before == after; build unaffected β€” next.config.ts has eslint.ignoreDuringBuilds).
  • Phase 4 is deliberately NOT in this PR. Removing the legacy fallback + stripping hasAnyRole from the four firestore.*.rules is 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")

  1. βœ… director-erl added to me@jefferochan.com β€” Firestore profile AND Firebase Auth custom claims (read-merge-write, never replaced blind). Idempotent one-off script, session scratchpad.
  2. βœ… Same for alisonhytang@gmail.com (owner chose parity over narrowing).
  3. βœ… auditing-accountant-erl badge now carries projects: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.
  4. Re-check (live, same harness): RESULT: ALL ACTIVE USERS COVERED β€” phase 4 GO β€” waion βœ“ (via the registry fix), jake βœ“, alison βœ“ + me βœ“ (both now super-admin + director-erl), suspended user skipped (fails all gates in both models).
  5. ⚠ 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.
  6. 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_ENABLED rescue valve kept) now gates all 10 API routes; the 3 client components pair useBadges() + canPerformActionIn under hide-until-confirmed; canPerformActionOrLegacy DELETED. 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-erl canReadProjects gains auditing-accountant-erl, mirroring fix 3). Badge helpers now require isActive() β€” 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 by deploy-firestore-rules.yml on 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-rule allowedRoles/requiredPermissions legacy fields + evaluatePageRule fallback (badge gate already short-circuits for badge holders); ROLE_PERMISSIONS consumers 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 against firestore.*.rules.
  • Do NOT route any new server path through the legacy GOOGLE_* fallback (Drive/BQ) β€” service@ was slated for deletion ~2026-07-12; use firebase-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.