Skip to content

Admin deletion cascade consolidation (issue #749) โ€‹

Date: 2026-07-31 Issues consolidated: #749 (primary), #236 (GitHub revoke on delete), refs #279 (callable migration parent), SECURITY_REMEDIATION.md M-ADMINDEL Status: SHIPPED on PR #757, dev smoke pending post-merge. Skills consulted: planning, privacy-architecture, cloud-serviceOperator decision already made (2026-07-30, recorded in #749 and SECURITY_REMEDIATION.md): Option 2, move the cascade itself out of Cloud Functions into a shared server-side home. The invoke-the-deployed-callable bridge was explicitly rejected (cross-service auth plumbing, keeps the CF alive forever).

1. How to read this doc โ€‹

Section 2 is the frozen baseline (what the two deletion paths actually do today, traced 2026-07-31). Sections 3 to 6 are the design: target architecture, route contract, retirement plan, test plan. Section 7 is risks and the privacy constraints that bind this work. Section 8 is explicit non-goals. Section 9 is the phased checklist, sized to one working session and one PR (rule 12). The PR body should carry Closes #749, Closes #236.

2. Baseline: the two deletion paths as traced (frozen 2026-07-31) โ€‹

Path A, the Cloud Function (deleteAdminUser, services/functions/firebase/modules/adminDeletion.js). Callable, zero callers. Kept deployed only because it is the live home of the full cascade (see the "Kept:" comment at services/functions/firebase/main.js line 22). It does, in order:

  1. Auth checks: authenticated caller, verifyAdminCaller, no self-delete, target's custom claim must be admin.
  2. GitHub username lookup: users/{uid}.githubUsername, falling back to adminProfiles/{uid}.githubUsername.
  3. GitHub collaborator removal via @octokit/rest repos.removeCollaborator (best-effort, logged and swallowed). NOTE: this is the pre-#236-fix behavior; it never cancels a pending repo invitation, so a deleted admin who had not yet accepted their invite keeps a live invitation. This is the #236 delete-path gap.
  4. cascadeDeleteUserData(userId, { initiatedBy, reason, selfDelete: false }), the full cascade (below).
  5. A second, admin-specific adminActions audit row (action: 'deleteAdminUser') recording targetEmail, githubUsername, githubRevoked, cascadeCounts.

The full cascade (cascadeDeleteUserData, services/functions/firebase/modules/userDeletion.js). Factored out of the deleteUserCompletely callable exactly so another server-side path could reuse it; performs NO authorization itself. Steps, in execution order, each isolated best-effort (a failed surface logs and continues):

#SurfaceTreatment
1lanterns where userIddelete, with per-doc beforeDelete hook deleting the lanternPins/{pinId} mirror (pinId only lives on the lantern doc)
2waves (senderUserId, then recipientUserId)delete, two passes
2bwaveSuppressions (own subcollection + collection-group where senderUserId)delete, needs the COLLECTION_GROUP index override
3connections (user1Id, then user2Id)delete, with messages subcollection drained per doc first
3bfrens (saverId, then savedId)delete
3cfeatureRequests where submittedBypseudonymize (submittedBy/userId set to 'deleted'), doc kept
3doffers where createdBypseudonymize createdBy, offer kept under its merchantId
3fbeaconInvites (senderId, then recipientId)delete
3gscheduledLanterns where userIddelete, with a per-doc transaction decrementing the venue's scheduledLanternCount (guarded at 0); failed hook leaves the doc for retry
3hcheckins where userIddelete
3jphoneReclaims (oldUserId, then requestedBy)delete (legacy rows can carry plaintext phone)
3eE2EE key material: userKeys/{uid} (+devices), toDevice/{uid} (+messages), secureBackup/{uid}delete, each surface in its own try/catch
3iProfile mirrors: publicProfiles/{uid}, adminProfiles/{uid}, merchantProfiles/{uid}delete, each isolated (belt-and-suspenders next to the syncPublicProfile trigger)
3j2moderationSignals/{uid} + events subcollectiondelete (derived aggregate; moderationCases/userReports deliberately NOT touched, see section 7)
4Storage avatars/{uid}/* in the default bucketbest-effort delete
5users/{uid} docdelete (late, so a partial run keeps a re-run anchor)
6Firebase Auth recorddelete last (auth/user-not-found treated as already-deleted)
7BigQuery analytics.eventsper-user pseudonymization: UPDATE ... SET user_id = SHA256(user_id || salt) with a fresh in-memory salt, the mandatory partition-filter clause (DATE(timestamp) >= 91 days back, required since #617's require_partition_filter), and the already-hashed guard regex. Salt never logged, returned, or persisted
8adminActions audit rowaction: 'deleteUserCompletely', userId-only references per docs/privacy/LOG_HYGIENE.md, counts, authDeleted, userDocDeleted

Returns { userId, deletedAt, counts, authDeleted, userDocDeleted }.

Path B, the REST route the admin portal actually calls (DELETE /auth/admin/users/:userId, services/api/auth/src/routes/adminUsers.js lines 234 to 269). Gated by verifyFirebaseToken + requireAdmin at the mount (src/index.js line 167, via the adminDispatch router). Thin cascade only: auth.deleteUser, users/{uid} delete, adminProfiles/{uid} delete, one audit row. No target-is-admin check, no GitHub revoke, and an admin's lanterns, waves, connections, frens, avatars, key material, and BigQuery rows all survive. This is M-ADMINDEL.

Client chain: UserDetailPanel.jsx handleDeleteAdmin calls deleteAdminUser(userId, deleteReason) from apps/admin/src/firebase.js (line 1228), which delegates to apps/admin/src/shared/lib/authApi.js deleteAdminUser (DELETE with JSON body { reason }). The UI IGNORES the response body on success (it shows its own static success string) and surfaces err.message on failure. So the response contract is free to grow, and error responses just need a sensible message.

Demotion sibling (already shipped, #279 / commit 8cb7d5ea): PUT /auth/roles/:userId (routes/roles.js) carries the demote cascade: revokeRepoAccess from services/githubCleanup.service.js (collaborator removal AND pending-invite cancellation, the #236 fix), adminProfiles delete, and an audit row with githubRevoked + githubInviteCancelled. The delete path must reuse this service, not fork it.

Consumers of the cascade after this work: zero remain in Functions. deleteUserCompletely has NO client callers (the self-delete UI was never wired; verified by grep across apps/), and adminDeletion.js is the only importer of cascadeDeleteUserData. So the cascade can move wholesale into auth-api; a shared package (packages/shared) home is unnecessary indirection for a single consumer and is rejected.

Runtime/IAM discovery that simplifies everything: auth-api and analytics-api both deploy WITHOUT --service-account (deploy-dev.yml), so both run as the project's default compute service account. analytics-api already runs BigQuery DML UPDATEs against analytics.events under that SA (userIdPseudonymization.service.js, the Sprint B.2 bulk job), and auth-api already uses firebase-admin/storage (appealDocs.service.js). Net: no new IAM grants are expected; the plan still includes a dev smoke verification rather than trusting this.

3. Target architecture โ€‹

New home: services/api/auth/src/services/deletionCascade.service.js. A direct port of cascadeDeleteUserData plus its helpers (deleteQueryInBatches, pseudonymizeFieldsInBatches, deleteCollectionInBatches, pseudonymizeUserBigQueryEvents, deleteUserStorage), adapted from Functions idioms to auth-api idioms:

  • logger (firebase-functions) becomes the pino logger pattern the service layer uses (accept an optional log param defaulting to console-safe no-ops, matching house style in other services).
  • db/getAuth come from firebase-admin/firestore / firebase-admin/auth directly (as adminUsers.js already does).
  • BigQuery: add @google-cloud/bigquery to auth-api's dependencies; lazy singleton client, project id from FIREBASE_PROJECT_ID (already set on the deploy) with the same fallbacks. Preserve VERBATIM: the fresh per-call salt that never escapes the stack frame, the already-hashed guard regex, and the 91-day partition-filter clause (removing it makes the UPDATE rejected and silently swallowed, the exact regression the userDeletion.js comment warns about).
  • Storage: the Functions version relies on getStorage().bucket() resolving the default bucket from FIREBASE_CONFIG, which Cloud Run does not set. Port the bucket-name resolution auth-api already has (src/lib/storageSigning.js pattern) but VERIFY the avatars bucket at implementation time: .env.development says the web app uploads to lantern-app-dev.appspot.com while storageSigning computes ${projectId}.firebasestorage.app. Resolve to whichever bucket actually holds avatars/; make it an explicit constant or env-derived helper with a comment, not an implicit default.
  • Keep the per-surface best-effort isolation, execution order, counts object, and return shape EXACTLY as they are. The order is load-bearing (user doc late as a re-run anchor, Auth last, BigQuery after Auth) and the tests below pin it.
  • Keep the cascade auth-free: the route enforces authorization, the service does not (same contract as today, restated in the module doc).

GitHub revocation: reuse, do not fork. The delete route calls revokeRepoAccess(githubUsername) from the existing services/githubCleanup.service.js, exactly as roles.js does (guarded try/catch even though the service is contractually never-throws). This automatically fixes the #236 delete-path gap: pending-invite cancellation happens on delete too, and githubInviteCancelled lands in the audit row and response. The username lookup (users doc, then adminProfiles fallback) is ported from adminDeletion.js and MUST run before the cascade deletes both docs.

Why auth-api and not a shared package or analytics-api: the only consumer is the admin-deletion route, which lives in auth-api; roles.js's GitHub cleanup and the admin-user CRUD already live there; and a future self-delete route (non-goal, section 8) would also be an auth-api surface. analytics-api owns the BULK pseudonymization job; the per-user variant is small, self-contained, and belongs with the deletion that triggers it (a cross-service call would reintroduce the exact auth plumbing the operator rejected).

4. Route design โ€‹

Method/path: unchanged, DELETE /auth/admin/users/:userId. Same mount, same gating (verifyFirebaseToken + requireAdmin on the adminDispatch /users mount). No client URL change; the existing authApi.js wrapper keeps working untouched.

Request: path param userId; JSON body { reason?: string } (validated with zod, z.object({ reason: z.string().nullish() }), matching roles.js's optional-reason posture; today's route reads it untyped).

Handler order:

  1. 422 SELF_DELETE if userId === callerUid (exists today, keep).
  2. auth.getUser(userId); 404 NOT_FOUND if missing.
  3. 422 NOT_ADMIN if the target's custom claim role is not admin (ported from the CF; the REST route is missing this check today and it is the guard that keeps this endpoint from being a delete-anyone hammer. Accept the Firestore-doc-role fallback the merchant delete route uses only if a legacy-claims admin actually surfaces; default to claims-only like the CF).
  4. GitHub username lookup (users doc, then adminProfiles), then revokeRepoAccess best-effort, capturing githubRevoked / githubInviteCancelled / errors (warn-logged), BEFORE the cascade destroys the lookup docs.
  5. cascadeDeleteUserData(userId, { initiatedBy: callerUid, reason, selfDelete: false }).
  6. Admin-specific adminActions row (action: 'deleteAdminUser') with githubUsername, githubRevoked, githubInviteCancelled, cascadeCounts, reason, performedBy, performedAt. Per docs/privacy/LOG_HYGIENE.md and the axiom, DROP targetEmail from this row (the CF recorded it; an email that outlives the deleted account in an audit log is retained PII we can only be hurt by keeping). Flag this in the PR body as a deliberate behavior change.
  7. Response 200: { userId, message: 'Admin user deleted', counts, authDeleted, userDocDeleted, githubRevoked, githubInviteCancelled, githubUsername }. Superset of today's { userId, message }; the UI ignores the body, so this is fully compatible while giving the portal room to render cascade counts later.

Error semantics: cascade-internal failures stay best-effort (logged, reflected in counts), matching today's CF behavior; the route only 4xx/5xxs on the guard failures above or a throw before the cascade starts. A cascade that ran but had partial surface failures still returns 200 with truthful counts, because a re-run is the recovery path (the user doc anchor makes re-runs safe and idempotent-ish).

openapi: auth-api is in ENFORCED_SERVICES, so update services/api/auth/openapi.json in the same commit: add the requestBody (reason) and replace the generic SuccessResponse ref with the real response schema. No new path entries, so the sync scanner is unaffected.

5. Migration and retirement โ€‹

Client flip: NONE required. The portal already calls the REST route; this work changes what the route does, not where the client points. The only client-side touch is comment hygiene in apps/admin/src/firebase.js / UserDetailPanel.jsx if any comment still describes the thin-delete or CF behavior.

Cloud Functions retirement, in the same PR:

  1. Delete services/functions/firebase/modules/adminDeletion.js (its only value was hosting the cascade call + the pre-fix octokit revoke).
  2. Delete services/functions/firebase/modules/userDeletion.js (the cascade's old home; deleteUserCompletely has zero callers and adminDeletion.js was the only importer of cascadeDeleteUserData).
  3. main.js: remove the deleteAdminUser export (line 24) and the deleteUserCompletely export (line 74); replace the "Kept: this holds the full deletion cascade" comment with a retirement note in the established style ("Admin/user deletion moved to auth-api DELETE /auth/admin/users/:userId + deletionCascade.service.js; retired in the #279/#749 clean-up (2026-07-31)").
  4. @octokit/rest in services/functions/firebase/package.json: adminDeletion.js is the ONLY importer left in the Functions workspace (verified by grep), so drop the dependency and refresh the lockfile. assistant-api and docs-api carry their own octokit deps and are untouched.
  5. config.js githubToken (defineSecret('GITHUB_TOKEN')): adminDeletion.js is its only direct importer. Remove the export if the implementation-time grep confirms no other consumer; leave getConfig()'s process.env.GITHUB_TOKEN line alone if any remaining function still declares the secret (verify, do not assume).
  6. Deployed function cleanup: the next Functions deploy drops the two exports; note in the PR body that deleteAdminUser and deleteUserCompletely disappear from the deployed function list (both have zero callers, so nothing breaks).

Docs, same PR: update SECURITY_REMEDIATION.md (M-ADMINDEL row in the tracker table at ~line 1455 to done-in-REST, the "Flagged (NOT done)" follow-up block at ~line 1268 to resolved-by-#749, same-PR-as-fix rule), and the daily changelog after the work is confirmed complete.

6. Test plan โ€‹

House pattern: route-handler-level tests with mocked firebase-admin (see roles.route.test.js) plus service-level unit tests. New files: src/services/__tests__/deletionCascade.service.test.js and src/routes/__tests__/adminUsers.delete.route.test.js (or extend an existing adminUsers test file if one exists for other verbs; there is none today).

Pin, at minimum:

  • Cascade order and coverage: the service touches every surface in section 2's table, in that order; user doc deleted after the per-collection cascades; Auth deleted last; BigQuery after Auth. A mock-ledger test that records call order is the cheap way to freeze this.
  • Best-effort semantics: a throwing surface (e.g. connections query rejects) does not abort later surfaces, is absent from counts, and the run still returns a result object. Also: the beforeDelete-hook failure path skips the parent doc (the lanternPins orphan guard) rather than deleting it.
  • BigQuery statement: generated SQL contains the partition-filter clause and the already-hashed regex guard; the salt is not present in the result, and nothing logs it (assert the log mock never received it). Missing project id skips with a warning, does not throw.
  • Storage: bucket resolution helper returns the verified avatars bucket; storage failure is non-fatal.
  • Route guards: self-delete 422, unknown user 404, non-admin target 422, and the happy path returns the superset contract with userId + message intact (the fields today's UI and any cached client depend on).
  • #236 invite-cancel on delete: with a githubUsername present, the route calls revokeRepoAccess BEFORE the cascade (order-pinned, since the cascade deletes the lookup docs) and the audit row + response carry githubRevoked and githubInviteCancelled. With no username: no GitHub call, fields false/null. revokeRepoAccess itself keeps its existing service tests; do not re-test its internals here.
  • Audit rows: exactly two adminActions writes (the cascade's deleteUserCompletely row and the route's deleteAdminUser row), and the route row contains NO email field.

Also run the openapi sync scope (npm run validate -- --scope openapi) and the full npm run validate once pre-PR (rule 3). Post-deploy dev smoke (operator or agent with gcloud): delete a throwaway dev admin and confirm counts come back non-zero for seeded surfaces and the BigQuery step reports rows (this is the IAM verification from section 2's discovery).

7. Risks and binding privacy constraints โ€‹

Privacy-architecture constraints this plan must not regress (the cascade is one of the axiom's enforcement points: prefer losing data over leaking it):

  • The moderation-evidence exemption is deliberate. moderationCases and userReports stay untouched by deletion; deleting them would let an account erase the record of its own reported behavior. moderationSignals (derived aggregate) DOES go. Preserve both sides exactly.
  • No recovery path. Deletion stays unrecoverable by design: no soft-delete, no export-before-delete, no server-held copy. Partial-failure recovery is re-running the cascade, never retaining data "just in case."
  • BigQuery pseudonymization is part of the deletion guarantee. The salt must remain ephemeral and unlogged, and the partition-filter clause must survive the port (its silent-rejection failure mode is documented in the source and pinned by a test).
  • LOG_HYGIENE: audit rows are userId-only; this plan removes the CF's targetEmail from the new route's audit row rather than porting it.
  • Retention symmetry: the cascade is what keeps the "purge over retain" story true for admin accounts too; landing it in the live route CLOSES a leak-shaped gap (orphaned connections/key material of deleted admins), which is the whole point of M-ADMINDEL.

Delivery risks:

  • Request-scoped runtime instead of a callable: the cascade now runs inside an HTTP request. Cloud Run's default timeout (300s) dwarfs any realistic admin-account cascade (admins have little social data), and every surface is paged; if a pathological account ever appears, re-run. No queueing infrastructure is warranted at pilot scale.
  • Auth-deletion ordering change for the REST path: today's thin route deletes Auth FIRST; the cascade deletes Auth LAST. For admin-initiated deletion of someone else this is fine (the target can still authenticate for a few seconds mid-cascade, then everything is gone), and it matches the CF behavior that was already the intended design. Called out so the diff reviewer does not read it as an accident.
  • Bucket-name ambiguity (appspot.com vs firebasestorage.app): resolved by verification at implementation time, not assumption; a wrong bucket silently no-ops the avatar cleanup (best-effort masks it), which is why the resolution helper gets its own test.
  • IAM assumption: shared default compute SA should make BigQuery DML and Storage delete just work (analytics-api proves the BQ half daily). If the dev smoke shows a permission denial, grant the missing role to the runtime SA then re-verify; this is a five-minute fix, not a design risk.
  • Two audit rows per deletion (cascade row + admin row) is today's CF behavior, preserved for query continuity in any adminActions consumers. Not a bug.

8. Non-goals โ€‹

  • No user-facing self-delete flow. The web "delete my account" UI and a POST /auth/user/delete-self-style route remain future work (the old deleteUserCompletely callable had zero callers, so nothing is lost by retiring it now). The cascade service is written auth-free so that route can reuse it when it comes.
  • No GitHub organization migration. #236's "suggested fix" (org token, org-level revocation) is out of scope; the shipped githubCleanup.service.js semantics (personal-repo collaborator removal + invite cancellation) are what the delete path adopts. #236's acceptance criteria are met by: demote path (shipped, #279), delete path (this work), manual revoke button (already served by the assistant-api GitHub routes), audit fields (both paths record revocation status).
  • No shared-package extraction. packages/shared gains nothing; the cascade has exactly one consumer service.
  • No merchant-deletion cascade upgrade. DELETE /auth/admin/users/merchant/:userId keeps its current thin-but-deliberate scope; if a full-cascade merchant delete is wanted, file it as its own issue (rule 12: issues are cheap).
  • No retention/purge changes. The scheduled purges in retention.js are untouched.
  • No queue/eventing infrastructure for deletion. Synchronous best-effort with re-run recovery is the design.

9. Phased checklist (one session, one PR) โ€‹

Session mechanics: one branch, draft PR opened early, one logical commit per phase, push immediately (rules 11/12/16). npm run validate once before marking anything ready.

Phase 1: port the cascade (commit 1)

  • [x] Add @google-cloud/bigquery to services/api/auth/package.json
  • [x] Create src/services/deletionCascade.service.js: port cascadeDeleteUserData + helpers verbatim in behavior (order, counts, best-effort isolation, salt hygiene, partition filter), adapted to auth-api logging and admin-SDK access
  • [x] Resolve and verify the avatars bucket name; encode it in a tested helper
  • [x] src/services/__tests__/deletionCascade.service.test.js per section 6

Phase 2: rewire the route (commit 2)

  • [x] Rewrite DELETE /auth/admin/users/:userId in adminUsers.js per section 4 (guards incl. target-is-admin, GitHub revoke via revokeRepoAccess before the cascade, cascade call, admin audit row without email, superset response)
  • [x] Update services/api/auth/openapi.json (requestBody + response schema)
  • [x] src/routes/__tests__/adminUsers.delete.route.test.js per section 6

Phase 3: retire the Cloud Functions (commit 3)

  • [x] Delete modules/adminDeletion.js and modules/userDeletion.js
  • [x] main.js: remove both exports, write the retirement notes
  • [x] Drop @octokit/rest from the Functions package.json (+ lockfile); remove the githubToken secret export from config.js if the grep confirms zero remaining consumers
  • [x] Sweep stale comments in apps/admin/src/firebase.js / UserDetailPanel.jsx that describe the old behavior

Phase 4: docs, validation, PR (commit 4)

  • [x] SECURITY_REMEDIATION.md: M-ADMINDEL tracker row + the flagged follow-up block updated to done-via-#749
  • [x] npm run validate (full), fix everything locally
  • [x] Changelog entry in docs/changelogs/dev/ once confirmed complete
  • [x] PR body: per-phase breakdown, Closes #749, Closes #236, the targetEmail-drop behavior change, and the Auth-ordering note from section 7
  • [ ] After the dev deploy lands: dev smoke per section 6 (throwaway admin, verify counts + BigQuery rows), then update this doc's Status line

Close-out note (2026-07-31): all phases shipped on PR #757. The dev changelog entry is produced by the AI Changelog Generator on merge (house convention), so no hand-written changelog file accompanies this plan.

Built with VitePress