Skip to content

feat(erasure): Phase 8C — RA 10173 Erasure & Anonymization#82

Merged
Exc1D merged 17 commits intomainfrom
feat/phase8c-erasure
Apr 29, 2026
Merged

feat(erasure): Phase 8C — RA 10173 Erasure & Anonymization#82
Exc1D merged 17 commits intomainfrom
feat/phase8c-erasure

Conversation

@Exc1D
Copy link
Copy Markdown
Owner

@Exc1D Exc1D commented Apr 29, 2026

Summary

Implements the full RA 10173 (Data Privacy Act) erasure execution loop: citizen deletion request submission, anonymization sweep, retention sweep, legal hold, and Firestore/Storage rules.

Changes

Backend — 4 new Cloud Functions + 1 fix

Function Type Purpose
requestDataErasure callable Citizen submits erasure request; sentinel idempotency prevents double-submission
setErasureLegalHold callable Superadmin + MFA pause/resume on approved requests
erasureSweep scheduled (15min) Sequential claim executor: anonymizes reports, nulls PII, deletes Auth
retentionSweep scheduled (24h) 1-week anonymize threshold + 1-month hard-delete for unverified reports
approveErasureRequest callable Fixed deny path: re-enables Auth, deletes sentinel, transaction gate on pending_review

Rules

  • Firestore: citizens can create/read their own erasure_requests and erasure_active sentinel; superadmins have full read; service accounts handle transitions.
  • Storage: citizens granted read access to report_media (architectural limitation: cannot scope to "own" media without Firestore cross-read).

Frontend — Citizen PWA

  • DeleteAccountFlow two-step confirmation modal with DELETE typing gate
  • GoodbyeScreen static post-submission page at /goodbye
  • ProfileTab wired with privacy section containing delete-account entry point

Tests

  • 5 new callable/trigger test files
  • 1 new Firestore rules test file
  • 1 new citizen PWA service test + 1 component test

Known Limitations / Open Items

  1. Pseudonymous erasure gap (RA 10173 §16): citizens who submitted via SMS before registering have no erasure path for pre-registration sms_inbox/sms_sessions data. Requires UID-linkage at registration time before SMS onboarding can go to production.
  2. Storage citizen read scope is all report media, not per-citizen. Storage rules cannot read Firestore to verify ownership.
  3. Emulator tests: rules-unit-testing and trigger tests require Firestore emulator on port 8081. They were verified structurally but need emulator runtime confirmation before merge.

Rollback

# Re-deploy functions from main
git checkout main
firebase deploy --only functions --project <project>

Verification

  • pnpm exec tsc --noEmit passes in functions and apps/citizen-pwa
  • Unit tests pass for citizen PWA components
  • Firestore emulator rules tests (requires firebase emulators:exec)
  • Functions trigger tests against emulator

Summary by Sourcery

Implement RA 10173-compliant citizen data erasure and retention flows across backend, security rules, and the citizen PWA.

New Features:

  • Add callable functions for citizens to request data erasure and for superadmins to set legal holds on erasure requests.
  • Introduce scheduled erasure and retention sweeps to anonymize or delete citizen data and associated reports based on status and age.
  • Expose a delete-account flow in the citizen PWA, including a guarded confirmation modal and a goodbye screen, wired to a new erasure service.

Bug Fixes:

  • Fix the erasure request approval callable so denial correctly re-enables the user, clears the active erasure sentinel, and prevents inconsistent status updates.

Enhancements:

  • Tighten Firestore and Storage rules around erasure-related collections and report media access to align with the erasure lifecycle.
  • Document Phase 8C progress and implementation learnings for RA 10173 erasure and anonymization flows.

Tests:

  • Add unit tests for erasure- and retention-sweep triggers, erasure-related callables, Firestore rules, and the citizen delete-account UX and service integration.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added account deletion flow with multi-step confirmation and warning dialogs
    • Added completion confirmation screen following successful deletion
  • Security

    • Updated access controls for erasure request management
    • Added legal hold capability for deletion requests
  • Documentation

    • Documented operational guidelines for data erasure workflows
  • Tests

    • Added comprehensive test coverage for account deletion and data retention operations

Exc1D added 15 commits April 29, 2026 09:23
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 29, 2026

Reviewer's Guide

Implements the RA 10173 data erasure execution loop end‑to‑end: citizen erasure request flow (backend callable + PWA UX), legal hold controls, scheduled erasure and retention sweeps (including SMS + Storage handling), updated Firestore/Storage rules, and tests for callables, triggers, and security rules, plus a bugfix to the erasure approval deny path.

Sequence diagram for citizen data erasure request and sign-out

sequenceDiagram
  actor Citizen
  participant DeleteAccountFlow
  participant ErasureService as requestDataErasureAndSignOut
  participant FirebaseFunctions as requestDataErasureCallable
  participant Firestore
  participant Auth
  participant Router as AppRouter

  Citizen->>DeleteAccountFlow: click Delete my account
  DeleteAccountFlow->>DeleteAccountFlow: step = warn
  Citizen->>DeleteAccountFlow: confirm warning
  DeleteAccountFlow->>DeleteAccountFlow: step = confirm
  Citizen->>DeleteAccountFlow: type DELETE and submit
  DeleteAccountFlow->>ErasureService: requestDataErasureAndSignOut()
  ErasureService->>FirebaseFunctions: call requestDataErasure
  FirebaseFunctions->>Firestore: runTransaction(create erasure_active, erasure_requests)
  Firestore-->>FirebaseFunctions: transaction committed
  FirebaseFunctions->>Auth: updateUser(uid, disabled true)
  Auth-->>FirebaseFunctions: success
  FirebaseFunctions-->>ErasureService: success
  ErasureService->>Auth: signOut()
  Auth-->>ErasureService: success
  ErasureService-->>DeleteAccountFlow: resolved
  DeleteAccountFlow->>Router: onGoodbye()
  Router->>Citizen: navigate to GoodbyeScreen

  rect rgb(255,230,230)
    FirebaseFunctions->>Firestore: if sentinel exists, abort
    FirebaseFunctions-->>ErasureService: HttpsError already_exists
    ErasureService-->>DeleteAccountFlow: reject
    DeleteAccountFlow->>DeleteAccountFlow: show error message
  end

  rect rgb(255,230,230)
    FirebaseFunctions->>Auth: updateUser disabled true
    Auth-->>FirebaseFunctions: failure
    FirebaseFunctions->>Firestore: delete erasure_requests, erasure_active
    Firestore-->>FirebaseFunctions: rollback complete
    FirebaseFunctions-->>ErasureService: HttpsError internal auth_disable_failed
    ErasureService-->>DeleteAccountFlow: reject
    DeleteAccountFlow->>DeleteAccountFlow: show error message
  end
Loading

Sequence diagram for scheduled erasure sweep execution

sequenceDiagram
  participant Scheduler as CloudScheduler
  participant ErasureSweep as erasureSweep
  participant Firestore
  participant Auth
  participant Storage
  participant Audit as AuditStream

  Scheduler->>ErasureSweep: trigger every 15 minutes
  ErasureSweep->>Firestore: query erasure_requests status approved_pending_anonymization and legalHold not true limit 1
  Firestore-->>ErasureSweep: readySnap
  ErasureSweep->>Firestore: query erasure_requests status executing and stale limit 1
  Firestore-->>ErasureSweep: staleSnap
  ErasureSweep->>Firestore: query erasure_requests status approved_pending_anonymization and legalHold true
  Firestore-->>ErasureSweep: heldSnap
  ErasureSweep->>ErasureSweep: pick candidate request
  alt no candidate
    ErasureSweep-->>Scheduler: return processed 0
  else candidate found
    ErasureSweep->>Firestore: update candidate status executing, set sweepRunId, executionStartedAt
    ErasureSweep->>Firestore: query reports where submittedBy equals citizenUid
    Firestore-->>ErasureSweep: reportsSnap
    loop for each reportId
      ErasureSweep->>Firestore: get report_private for reportId
      Firestore-->>ErasureSweep: privateSnap
      ErasureSweep->>ErasureSweep: collect senderMsisdnHash
    end
    loop anonymize reports
      ErasureSweep->>Firestore: update reports submittedBy citizen_deleted, mediaRedacted true
      ErasureSweep->>Firestore: update report_private PII fields null
      ErasureSweep->>Firestore: get report_contacts
      Firestore-->>ErasureSweep: contactSnap
      ErasureSweep->>Firestore: update report_contacts all fields except reportId null
    end
    loop null sms_sessions and sms_inbox
      ErasureSweep->>Firestore: query sms_sessions by senderMsisdnHash
      Firestore-->>ErasureSweep: sessSnap
      ErasureSweep->>Firestore: update sms_sessions senderMsisdnHash null, msisdn null
      ErasureSweep->>Firestore: query sms_inbox by senderMsisdnHash
      Firestore-->>ErasureSweep: inboxSnap
      ErasureSweep->>Firestore: update sms_inbox senderMsisdnHash null, msisdn null, rawBody null
    end
    loop delete Storage media
      ErasureSweep->>Storage: bucket getFiles prefix report_media/
      Storage-->>ErasureSweep: files
      ErasureSweep->>Storage: delete files matching reportId
    end
    ErasureSweep->>Auth: deleteUser citizenUid
    Auth-->>ErasureSweep: success
    ErasureSweep->>Firestore: delete erasure_active sentinel
    ErasureSweep->>Firestore: update erasure_request status completed, completedAt
    ErasureSweep->>Audit: streamAuditEvent erasure_completed
    ErasureSweep-->>Scheduler: result processed 1
  end

  rect rgb(255,230,230)
    ErasureSweep->>ErasureSweep: executeErasure throws error
    ErasureSweep->>Auth: updateUser citizenUid disabled false
    Auth-->>ErasureSweep: success or failure logged
    ErasureSweep->>Firestore: update erasure_request status dead_lettered, deadLetterReason, deadLetteredAt
    ErasureSweep->>Audit: streamAuditEvent erasure_request_dead_lettered_with_auth_unblocked
  end
Loading

Sequence diagram for superadmin approval and denial of erasure requests

sequenceDiagram
  actor Superadmin
  participant ApproveCallable as approveErasureRequest
  participant Firestore
  participant Auth
  participant Audit as AuditStream

  Superadmin->>ApproveCallable: approveErasureRequest approved true
  ApproveCallable->>Firestore: runTransaction get erasure_requests doc
  Firestore-->>ApproveCallable: snap
  alt status not pending_review
    ApproveCallable-->>Superadmin: HttpsError failed_precondition erasure_already_reviewed
  else status pending_review
    ApproveCallable->>Firestore: tx update status approved_pending_anonymization, reviewedBy, reviewedAt, reviewReason
    Firestore-->>ApproveCallable: transaction committed
    ApproveCallable->>Audit: streamAuditEvent erasure_request_reviewed metadata approved true
    ApproveCallable-->>Superadmin: success
  end

  Superadmin->>ApproveCallable: approveErasureRequest approved false
  ApproveCallable->>Firestore: get erasure_requests doc
  Firestore-->>ApproveCallable: snap
  alt status not pending_review
    ApproveCallable-->>Superadmin: HttpsError failed_precondition erasure_already_reviewed
  else status pending_review
    ApproveCallable->>Auth: updateUser citizenUid disabled false
    Auth-->>ApproveCallable: success
    ApproveCallable->>Firestore: runTransaction update request status denied and delete erasure_active sentinel
    alt transaction success
      Firestore-->>ApproveCallable: committed
      ApproveCallable->>Audit: streamAuditEvent erasure_request_reviewed metadata approved false
      ApproveCallable-->>Superadmin: success
    else transaction fails
      Firestore-->>ApproveCallable: error
      ApproveCallable->>Auth: updateUser citizenUid disabled true
      Auth-->>ApproveCallable: best effort
      ApproveCallable-->>Superadmin: HttpsError internal deny_write_failed
    end
  end
Loading

ER diagram for erasure and retention related collections

erDiagram
  ERASURE_REQUESTS {
    string id
    string citizenUid
    string status
    boolean legalHold
    number requestedAt
    number reviewedAt
    string reviewedBy
    string reviewReason
    string sweepRunId
    number executionStartedAt
    number completedAt
    string deadLetterReason
    number deadLetteredAt
  }

  ERASURE_ACTIVE {
    string citizenUid
    number createdAt
  }

  REPORTS {
    string id
    string submittedBy
    boolean verified
    number submittedAt
    boolean mediaRedacted
    number retentionAnonymizedAt
    number retentionHardDeleteEligibleAt
  }

  REPORT_PRIVATE {
    string reportId
    string citizenName
    string rawPhone
    string gpsExact
    string addressText
    string senderMsisdnHash
  }

  REPORT_CONTACTS {
    string reportId
  }

  SMS_SESSIONS {
    string id
    string senderMsisdnHash
    string msisdn
  }

  SMS_INBOX {
    string id
    string senderMsisdnHash
    string msisdn
    string rawBody
  }

  RETENTION_AUDIT_LOG {
    string id
    string reportId
    number retentionDeletedAt
    string reason
  }

  ERASURE_ACTIVE ||--|| ERASURE_REQUESTS : citizenUid
  ERASURE_REQUESTS ||--o{ REPORTS : anonymizes_reports_of
  ERASURE_REQUESTS ||--o{ SMS_SESSIONS : nulls_sessions_for
  ERASURE_REQUESTS ||--o{ SMS_INBOX : nulls_messages_for
  REPORTS ||--|| REPORT_PRIVATE : has_private
  REPORTS ||--|| REPORT_CONTACTS : has_contacts
  REPORT_PRIVATE }o--o{ SMS_SESSIONS : linked_by_senderMsisdnHash
  REPORT_PRIVATE }o--o{ SMS_INBOX : linked_by_senderMsisdnHash
  REPORTS ||--o{ RETENTION_AUDIT_LOG : deletion_logged_in
Loading

Class diagram for erasure and retention core functions and PWA components

classDiagram
  class RequestDataErasureCore {
    +requestDataErasureCore(db Firestore, auth Auth, actor Actor) void
  }

  class ApproveErasureRequestCore {
    +approveErasureRequestCore(db Firestore, auth Auth, input unknown, actor Actor) void
  }

  class SetErasureLegalHoldCore {
    +setErasureLegalHoldCore(db Firestore, input unknown, actor Actor) void
  }

  class ErasureSweepCore {
    +erasureSweepCore(input ErasureSweepInput) ErasureSweepResult
    +executeErasure(input ErasureSweepInput, citizenUid string, requestId string) void
  }

  class RetentionSweepCore {
    +retentionSweepCore(input RetentionSweepInput) RetentionSweepResult
  }

  class ErasureSweepInput {
    +db Firestore
    +auth Auth
    +storage Storage
    +now function
  }

  class ErasureSweepResult {
    +processed number
    +skippedHeld number
    +deadLettered number
  }

  class RetentionSweepInput {
    +db Firestore
    +storage Storage
    +now function
  }

  class RetentionSweepResult {
    +anonymized number
    +hardDeleted number
  }

  class Actor {
    +uid string
  }

  class DeleteAccountFlowComponent {
    +step Step
    +typed string
    +error string
    +DeleteAccountFlow(onGoodbye function) void
    -handleConfirm() void
  }

  class GoodbyeScreenComponent {
    +GoodbyeScreen() void
  }

  class ErasureService {
    +requestDataErasureAndSignOut() void
  }

  class Step {
  }

  RequestDataErasureCore --> Actor
  ApproveErasureRequestCore --> Actor
  SetErasureLegalHoldCore --> Actor
  ErasureSweepCore --> ErasureSweepInput
  ErasureSweepCore --> ErasureSweepResult
  RetentionSweepCore --> RetentionSweepInput
  RetentionSweepCore --> RetentionSweepResult

  DeleteAccountFlowComponent --> Step
  DeleteAccountFlowComponent --> ErasureService
  ErasureService --> RequestDataErasureCore
Loading

File-Level Changes

Change Details Files
Add citizen-initiated erasure request callable with sentinel-based idempotency and Auth disable semantics.
  • Introduce requestDataErasureCore callable that creates an erasure_requests document and erasure_active sentinel in a Firestore transaction and then disables the user in Firebase Auth, rolling back Firestore docs on Auth failure.
  • Export requestDataErasure from the functions index for deployment.
  • Add backend tests covering successful creation, duplicate sentinel rejection, and rollback behavior on Auth errors.
functions/src/callables/request-data-erasure.ts
functions/src/index.ts
functions/src/__tests__/callables/request-data-erasure.test.ts
Fix and harden approve-erasure-request callable, including correct deny behavior and transactional status gating.
  • Refactor approveErasureRequestCore to accept an Auth instance and perform a transactional status check specifically against pending_review before approving.
  • Implement the deny path to re-enable the citizen’s Auth account, update the erasure_requests document to denied, delete the erasure_active sentinel in a transaction, and roll back Auth if the write fails.
  • Simplify input validation error handling and ensure audit events include approved true/false.
  • Wire the callable wrapper to pass getAuth() and add tests for approve, deny, status precondition failures, and deny rollback path.
functions/src/callables/approve-erasure-request.ts
functions/src/__tests__/callables/approve-erasure-request.test.ts
Add legal hold management callable to pause/resume erasure execution on non-terminal requests.
  • Implement setErasureLegalHoldCore with input validation, not-found handling, and terminal-status guard, updating legalHold metadata and emitting audit events.
  • Expose setErasureLegalHold as a superadmin+MFA-enforced callable and export it from the functions index.
  • Add tests verifying setting, clearing, and rejection on missing or terminal requests.
functions/src/callables/set-erasure-legal-hold.ts
functions/src/index.ts
functions/src/__tests__/callables/set-erasure-legal-hold.test.ts
Implement scheduled erasure sweep that anonymizes citizen data across Firestore, SMS, Storage, and Auth with dead-letter handling.
  • Create erasureSweepCore that sequentially claims one approved_pending_anonymization or stale executing erasure_requests record, marks it executing with a sweepRunId, and processes it.
  • Implement executeErasure to collect reports by submittedBy, anonymize reports and report_private, null out report_contacts and SMS (sms_sessions, sms_inbox) by senderMsisdnHash, delete report_media blobs, then hard-delete the Firebase Auth user, logging completion.
  • Handle failures by logging, attempting to re-enable Auth, marking the request dead_lettered with a reason, and streaming an audit event; count legalHold-suppressed records for observability.
  • Expose erasureSweep as a 15-minute scheduled function and add tests for happy path, legalHold skipping, pseudonymous report behavior, dead-letter + Auth re-enable, and stale executing re-claiming.
functions/src/triggers/erasure-sweep.ts
functions/src/__tests__/triggers/erasure-sweep.test.ts
functions/src/index.ts
Implement scheduled retention sweep to anonymize and later hard-delete unverified reports under retention policy while excluding active erasure cases.
  • Create retentionSweepCore that builds an in-memory set of citizen UIDs with active erasure_requests, then anonymizes unverified reports older than one week by nulling report_private and report_contacts PII, removing Storage media, optionally nulling SMS records by senderMsisdnHash, and setting retentionAnonymizedAt/retentionHardDeleteEligibleAt.
  • Implement a second phase to hard-delete reports whose retentionHardDeleteEligibleAt is in the past, deleting report_private and report_contacts and writing a retention_audit_log entry, with error logging on failures.
  • Expose retentionSweep as a daily scheduled function and add tests for anonymization, skipping already erased or active-erasure reports, and hard-delete behavior.
functions/src/triggers/retention-sweep.ts
functions/src/__tests__/triggers/retention-sweep.test.ts
functions/src/index.ts
Tighten Firestore and Storage security rules for erasure-related collections and citizen media access, with tests.
  • Update firestore.rules to define access patterns for erasure_requests and erasure_active, allowing citizens to create/read their own documents, forbidding client updates after creation, and granting broader read access to superadmins/service accounts.
  • Update storage.rules so citizens can read report_media blobs (not scoped per-user due to Storage rules constraints).
  • Add rules-unit-testing coverage asserting citizen and superadmin behaviors for erasure_requests and erasure_active collections.
infra/firebase/firestore.rules
infra/firebase/storage.rules
functions/src/__tests__/rules/erasure-requests.rules.test.ts
Add citizen PWA erasure UX: delete-account flow, goodbye screen route, and erasure service integration.
  • Introduce DeleteAccountFlow component implementing a multi-step confirmation (initial button, warning dialog, DELETE typing gate) that calls requestDataErasureAndSignOut and surfaces failures.
  • Add GoodbyeScreen component and wire routes: ProfileTab now contains a privacy section with DeleteAccountFlow, and a /goodbye route hides the bottom nav and shows post-submission messaging.
  • Implement requestDataErasureAndSignOut service that calls the requestDataErasure callable and then signs the user out via Firebase Auth.
  • Add component tests validating DeleteAccountFlow behavior, including step transitions, typing gate, successful goodbyes, and error display.
apps/citizen-pwa/src/components/DeleteAccountFlow.tsx
apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx
apps/citizen-pwa/src/components/GoodbyeScreen.tsx
apps/citizen-pwa/src/services/erasure.ts
apps/citizen-pwa/src/routes.tsx
Update documentation to reflect Phase 8C completion and capture erasure-specific implementation learnings.
  • Update progress.md to set the current phase to 8C, mark individual erasure tasks as DONE, and document the remaining SMS pseudonymous erasure gap as a production launch blocker.
  • Extend learnings.md with RA 10173 erasure implementation notes (write-before-disable invariant, erasure_active sentinel, sequential sweep, retention sweep behavior, retentionHardDeleteEligibleAt field, and SMS schema considerations).
docs/progress.md
docs/learnings.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

Warning

Rate limit exceeded

@Exc1D has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 35 minutes and 42 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 022e26cc-05b9-42ed-85e3-7fa32e87040b

📥 Commits

Reviewing files that changed from the base of the PR and between b986bfa and 2eee2ac.

📒 Files selected for processing (17)
  • apps/citizen-pwa/src/__tests__/erasure.test.ts
  • apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx
  • apps/citizen-pwa/src/components/DeleteAccountFlow.tsx
  • apps/citizen-pwa/src/services/erasure.ts
  • functions/src/__tests__/callables/approve-erasure-request.test.ts
  • functions/src/__tests__/callables/request-data-erasure.test.ts
  • functions/src/__tests__/callables/set-erasure-legal-hold.test.ts
  • functions/src/__tests__/rules/erasure-requests.rules.test.ts
  • functions/src/__tests__/triggers/erasure-sweep.test.ts
  • functions/src/callables/approve-erasure-request.ts
  • functions/src/callables/request-data-erasure.ts
  • functions/src/callables/set-erasure-legal-hold.ts
  • functions/src/triggers/erasure-sweep.ts
  • functions/src/triggers/retention-sweep.ts
  • infra/firebase/firestore.rules
  • infra/firebase/firestore.rules.template
  • infra/firebase/storage.rules
📝 Walkthrough

Walkthrough

This PR implements Phase 8C (RA 10173 compliance) with complete data erasure and anonymization. It adds user-facing account deletion UI, Firebase callables for requesting and managing erasure requests, scheduled sweeps that anonymize and hard-delete citizen data, and corresponding Firestore/Storage security rules with comprehensive test coverage.

Changes

Cohort / File(s) Summary
Frontend UI Components
apps/citizen-pwa/src/components/DeleteAccountFlow.tsx, apps/citizen-pwa/src/components/GoodbyeScreen.tsx, apps/citizen-pwa/src/routes.tsx
Added multi-step account deletion dialog (warning, confirmation, type "DELETE"), goodbye screen displayed post-deletion, and /profile and /goodbye routes with bottom nav hiding.
Frontend Services
apps/citizen-pwa/src/services/erasure.ts
New requestDataErasureAndSignOut service that invokes Firebase erasure callable then signs out the user.
Frontend Tests
apps/citizen-pwa/src/__tests__/erasure.test.ts, apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx
Test coverage for erasure service (success, failure scenarios) and DeleteAccountFlow component (UI interactions, user input validation, erasure call handling).
Backend Callables - Erasure Request
functions/src/callables/request-data-erasure.ts
New callable that creates erasure_requests and erasure_active sentinel docs, disables Auth, emits audit event; fails if erasure already active.
Backend Callables - Approval & Legal Hold
functions/src/callables/approve-erasure-request.ts, functions/src/callables/set-erasure-legal-hold.ts
Updated approval callable with explicit approve/deny branching and Auth parameter; new legal hold callable for superadmin hold/release management with validation and audit events.
Backend Triggers - Erasure Sweep
functions/src/triggers/erasure-sweep.ts
Scheduled trigger selecting eligible erasure requests, anonymizing/nulling PII across reports/contacts/SMS records, deleting Storage media and Auth user, marking completed or dead-lettered on failure.
Backend Triggers - Retention Sweep
functions/src/triggers/retention-sweep.ts
Daily scheduled trigger anonymizing unverified reports older than one week (excluding active erasures), hard-deleting when retention period elapsed, updating timestamps and logging results.
Backend Tests - Callables
functions/src/__tests__/callables/request-data-erasure.test.ts, functions/src/__tests__/callables/approve-erasure-request.test.ts, functions/src/__tests__/callables/set-erasure-legal-hold.test.ts
Comprehensive test suites covering request creation with sentinel/Auth disable, approval/denial with Auth rollback, legal hold set/clear, and error cases (already-exists, not-found, failed-precondition).
Backend Tests - Triggers
functions/src/__tests__/triggers/erasure-sweep.test.ts, functions/src/__tests__/triggers/retention-sweep.test.ts
Test harnesses validating sweep selection, anonymization of PII fields, Auth hard-delete, legal hold skipping, stale request reclaim, rollback on failure, and retention period logic.
Backend Tests - Rules
functions/src/__tests__/rules/erasure-requests.rules.test.ts
Firestore security rules tests verifying citizen create/read own requests, prevent unauthorized updates, superadmin read, and erasure-active sentinel access control.
Security Rules
infra/firebase/firestore.rules, infra/firebase/storage.rules
New /erasure_requests and /erasure_active match blocks with citizen create/read constraints; storage read access for citizens to report media.
Exports & Index
functions/src/index.ts
Exports new callables (requestDataErasure, setErasureLegalHold) and triggers (erasureSweep, retentionSweep).
Documentation
docs/learnings.md, docs/progress.md
Added Phase 8C learnings describing erasure callable rules, sweep sequencing, legal hold atomicity, Auth hard-delete ordering, and retention field requirements; marked tasks DONE with production blocker for pseudonymous SMS-submitted erasure path.

Sequence Diagram(s)

sequenceDiagram
    participant User as Citizen
    participant UI as DeleteAccountFlow
    participant Service as erasure Service
    participant Callable as requestDataErasure<br/>(Callable)
    participant Firestore as Firestore
    participant Auth as Firebase Auth
    participant ErasureSweep as erasureSweep<br/>(Trigger)
    participant Storage as Cloud Storage

    User->>UI: Click "Delete my account"
    UI->>UI: Show warning modal
    User->>UI: Confirm & type "DELETE"
    UI->>Service: requestDataErasureAndSignOut()
    Service->>Callable: Invoke requestDataErasure({})
    Callable->>Firestore: Create erasure_requests (pending_review)
    Callable->>Firestore: Create erasure_active sentinel
    Callable->>Auth: Disable citizen account
    Callable-->>Service: Success
    Service->>Auth: signOut()
    Service-->>UI: Complete
    UI->>UI: Call onGoodbye(), navigate to /goodbye

    Note over ErasureSweep: Scheduled every 15 minutes
    ErasureSweep->>Firestore: Select eligible erasure_requests
    ErasureSweep->>Firestore: Set status=executing
    ErasureSweep->>Firestore: Anonymize reports/contacts
    ErasureSweep->>Firestore: Null PII fields in report_private
    ErasureSweep->>Storage: Delete report_media blobs
    ErasureSweep->>Auth: Hard delete citizen account
    ErasureSweep->>Firestore: Set status=completed
    ErasureSweep->>Firestore: Delete erasure_active sentinel
    ErasureSweep-->>Firestore: Emit audit event
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 A citizen's path to oblivion, cleansed,

Requests and sweeps in concert advanced—

Sentinel docs and legal holds dance,

Auth hard-delete marks the final stance.✨

RA's compliance now enhanced!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and specifically describes the main change: implementing Phase 8C of RA 10173 Erasure & Anonymization across the backend, rules, and frontend, which is the primary purpose of this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/phase8c-erasure

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 35 minutes and 42 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • In approveErasureRequestCore the deny path reads the request and checks status !== 'pending_review' outside of the transaction that later updates the doc and deletes the sentinel, so a concurrent approve/deny can still both succeed; consider moving the read + status check into the same transaction that performs the update/delete, similar to the approve path.
  • Both executeErasure and retentionSweepCore delete Storage blobs by calling bucket().getFiles({ prefix: 'report_media/' }) and then filtering includes(reportId) for each report, which will become very inefficient as the bucket grows; it would be more scalable to either structure file paths so you can query with a more specific prefix per report, or fetch the file list once and group by report ID instead of per-report scans.
  • In erasureSweepCore the claim step performs a blind candidate.ref.update({ status: 'executing', ... }) without verifying the prior status or sweepRunId, so two sweep invocations could race and both consider the same request claimed; using a transaction or an update with preconditions on the expected status/timestamps would make the claim truly exclusive.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `approveErasureRequestCore` the deny path reads the request and checks `status !== 'pending_review'` outside of the transaction that later updates the doc and deletes the sentinel, so a concurrent approve/deny can still both succeed; consider moving the read + status check into the same transaction that performs the update/delete, similar to the approve path.
- Both `executeErasure` and `retentionSweepCore` delete Storage blobs by calling `bucket().getFiles({ prefix: 'report_media/' })` and then filtering `includes(reportId)` for each report, which will become very inefficient as the bucket grows; it would be more scalable to either structure file paths so you can query with a more specific prefix per report, or fetch the file list once and group by report ID instead of per-report scans.
- In `erasureSweepCore` the claim step performs a blind `candidate.ref.update({ status: 'executing', ... })` without verifying the prior status or `sweepRunId`, so two sweep invocations could race and both consider the same request claimed; using a transaction or an update with preconditions on the expected status/timestamps would make the claim truly exclusive.

## Individual Comments

### Comment 1
<location path="functions/src/callables/approve-erasure-request.ts" line_range="55-32" />
<code_context>
+    return
+  }
+
+  // Deny path: re-enable Auth → update doc + delete sentinel → rollback on failure.
+  const snap = await requestRef.get()
+  if (!snap.exists) throw new HttpsError('not-found', 'erasure_request_not_found')
+  if (snap.data()?.status !== 'pending_review') {
+    throw new HttpsError('failed-precondition', 'erasure_already_reviewed')
+  }
+  const citizenUid = snap.data()?.citizenUid as string
+
+  await auth.updateUser(citizenUid, { disabled: false })
+
+  try {
+    // eslint-disable-next-line @typescript-eslint/require-await
+    await db.runTransaction(async (tx) => {
+      const sentinelRef = db.collection('erasure_active').doc(citizenUid)
+      tx.update(requestRef, {
</code_context>
<issue_to_address>
**issue (bug_risk):** Re-check request status inside the deny transaction to avoid races with concurrent approvals.

The deny flow checks `status !== 'pending_review'` before `auth.updateUser`, but the transaction then calls `tx.update(requestRef, { status: 'denied', ... })` without re-reading or validating `status`. If an approval transaction commits between the initial `get()` and this transaction, a legitimate status like `approved_pending_anonymization` or `executing` can be overwritten to `denied`. Please move the status check into the transaction (re-read the doc and verify `status === 'pending_review'`) so the write is conditional and aborts if the status has changed.
</issue_to_address>

### Comment 2
<location path="functions/src/triggers/erasure-sweep.ts" line_range="28-37" />
<code_context>
+  // Sequential claim: fetch one ready record (or one stale executing record).
</code_context>
<issue_to_address>
**issue (bug_risk):** Claiming a request via plain update can lead to double-processing and inconsistent final status.

Two sweep runs can read the same `approved_pending_anonymization` doc before either updates it and both call `update({ status: 'executing', ... })` on the same `candidate.ref`, then both run `executeErasure`. The second run will typically fail at `auth.deleteUser`, hit the catch block, attempt to re-enable Auth for an already-deleted user, and overwrite the status to `dead_lettered` even though erasure actually succeeded. To prevent this double-processing and status corruption, claim the record in a transaction that (1) re-reads the doc, (2) confirms it is still `approved_pending_anonymization` (or a stale `executing`), and (3) updates to `executing` only under that precondition. When recovering stale `executing` records, also verify the existing `status` and `sweepRunId`.
</issue_to_address>

### Comment 3
<location path="functions/src/triggers/erasure-sweep.ts" line_range="185-187" />
<code_context>
+    }
+  }
+
+  // Step 8: Delete Storage blobs for all citizen reports (verified and unverified)
+  for (const reportId of reportIds) {
+    const [files] = await input.storage.bucket().getFiles({ prefix: `report_media/` })
+    for (const file of files.filter((f) => f.name.includes(reportId))) {
+      await file.delete()
</code_context>
<issue_to_address>
**suggestion (performance):** Storage deletion loops repeatedly list the entire `report_media/` prefix, which will not scale.

For each `reportId`, this re-runs `getFiles({ prefix: 'report_media/' })` and then filters client-side by `reportId`, so the total work is O(N²) and will scale poorly as the bucket grows. If possible, either: (a) structure storage so each reportId has its own prefix (e.g. `report_media/${reportId}/`) and list by that, or (b) list once outside the loop and group files by `reportId` before deleting.

Suggested implementation:

```typescript
  // Step 8: Delete Storage blobs for all citizen reports (verified and unverified)
  for (const reportId of reportIds) {
    // List only blobs under the per-report prefix to avoid repeatedly scanning the entire bucket
    const [files] = await input.storage
      .bucket()
      .getFiles({ prefix: `report_media/${reportId}/` })

    for (const file of files) {
      await file.delete()
    }
  }

```

To fully implement this optimization you should ensure that all report media is written using a per-report prefix that matches this deletion pattern, e.g. `report_media/${reportId}/...`. If the current upload paths differ (for example, `report_media/${reportId}-foo.jpg` or another naming convention), update the `prefix` passed to `getFiles` here to match that structure and/or adjust the upload paths in the relevant write code so each report's media lives under its own prefix.
</issue_to_address>

### Comment 4
<location path="functions/src/triggers/retention-sweep.ts" line_range="78-82" />
<code_context>
+        await input.db.collection('report_contacts').doc(doc.id).update(nulled)
+      }
+
+      // Delete Storage blobs
+      const [files] = await input.storage.bucket().getFiles({ prefix: 'report_media/' })
+      for (const file of files.filter((f) => f.name.includes(doc.id))) {
+        await file.delete()
</code_context>
<issue_to_address>
**suggestion (performance):** Retention sweep also re-lists all media per report, leading to quadratic storage listing cost.

This currently lists all `report_media/` files for every report and then filters by `doc.id`, which yields quadratic listing cost on large buckets and can hit storage/listing limits. Prefer using a report-specific prefix so you can list only that report’s files, or refactor to perform one listing per run and reuse the result across deletions.

```suggestion
      // Delete Storage blobs for this report only
      const [files] = await input.storage.bucket().getFiles({
        prefix: `report_media/${doc.id}`,
      })
      for (const file of files) {
        await file.delete()
      }
```
</issue_to_address>

### Comment 5
<location path="apps/citizen-pwa/src/components/DeleteAccountFlow.tsx" line_range="44" />
<code_context>
+
+  if (step === 'warn') {
+    return (
+      <div role="dialog" aria-modal="true">
+        <h2>Delete your account?</h2>
+        <p>This will permanently:</p>
+        <ul>
+          <li>Remove your name, contact info, and account</li>
+          <li>Anonymize your reports (they remain as public record)</li>
+          <li>Sign you out immediately</li>
+        </ul>
+        <p>This cannot be undone. Your request will be reviewed before deletion is complete.</p>
+        <button
+          onClick={() => {
+            setStep('idle')
+          }}
+        >
+          Cancel
+        </button>
+        <button
+          onClick={() => {
+            setStep('confirm')
+          }}
+        >
+          Yes, delete my account →
+        </button>
+      </div>
+    )
+  }
+
+  return (
+    <div role="dialog" aria-modal="true">
+      <h2>Are you sure?</h2>
+      <label htmlFor="delete-confirm">Type DELETE to confirm</label>
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Dialogs lack accessible labelling, which may make them hard to use with assistive tech.

These dialogs set `role="dialog"` and `aria-modal="true"` but lack an accessible name (`aria-labelledby` or `aria-label`), so screen readers may treat them as unnamed. Since you already render headings (e.g. "Delete your account?", "Are you sure?"), give those headings `id`s and reference them from the dialog via `aria-labelledby` so the modal’s purpose is announced to assistive technology.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread functions/src/callables/approve-erasure-request.ts
Comment thread functions/src/triggers/erasure-sweep.ts
Comment thread functions/src/triggers/erasure-sweep.ts Outdated
Comment thread functions/src/triggers/retention-sweep.ts Outdated
Comment thread apps/citizen-pwa/src/components/DeleteAccountFlow.tsx Outdated
- Update firestore.rules.template with erasure_requests/erasure_active rules
- Move deny-path status check inside transaction (race condition fix)
- Wrap erasureSweep claim in transaction with status precondition
- Use per-report storage prefix for blob deletion (performance)
- Add aria-labelledby to DeleteAccountFlow dialogs (a11y)
- Preserve HttpsError re-throw in deny catch block
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 21

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx`:
- Around line 6-9: Hoist the erasure mock by wrapping its declaration in
vi.hoisted so each test gets isolated control over
mockResolvedValueOnce/mockRejectedValueOnce; specifically, move the current
mockErasure = vi.fn() into a vi.hoisted block and return it from the vi.mock
factory used for requestDataErasureAndSignOut so the module factory references
the hoisted mockErasure (not an uninitialized variable) when mocking
requestDataErasureAndSignOut.

In `@apps/citizen-pwa/src/components/DeleteAccountFlow.tsx`:
- Around line 61-63: The handlers that change the flow step (e.g., the onClick
currently calling setStep('confirm') and the other handler at the second
location) leave previous confirmation state (typed and error) intact; add logic
to reset the confirmation state by clearing typed and error whenever you exit or
reopen the dialog—either call setTyped('') and setError(null) directly in those
onClick handlers or introduce a small helper like resetConfirmationState() and
call it alongside setStep(...) in the relevant handlers (references: setStep,
setTyped, setError, and the onClick handlers that set the step).
- Around line 22-25: The catch block in DeleteAccountFlow.tsx currently swallows
all errors and sets a generic failure via setError and setStep('confirm');
change the catch to capture the error object (catch (err)) and branch on the
erasure-conflict sentinel (e.g. err.code === 'already-exists' or
err.message.includes('already-exists')) so that when an idempotent
"already-exists" erasure response occurs you treat it as a recoverable/expected
state (advance the flow or show a success/info message) instead of marking it as
a hard failure; keep the existing generic setError/setStep('confirm') behavior
for other errors.

In `@apps/citizen-pwa/src/services/erasure.ts`:
- Around line 4-7: The requestDataErasureAndSignOut function must treat a
backend error code 'already-exists' as success and always complete sign-out;
wrap the httpsCallable(fns(), 'requestDataErasure')({}) call in a try/catch, if
the caught error has code === 'already-exists' swallow it (or log and continue),
otherwise rethrow or surface it, and ensure signOut(auth()) is called in a
finally or after handling so the session is always terminated; refer to
requestDataErasureAndSignOut, httpsCallable, 'requestDataErasure', signOut,
fns() and auth() to locate where to apply this change.

In `@functions/src/__tests__/callables/approve-erasure-request.test.ts`:
- Around line 97-109: The test is a placeholder and must actually invoke the
code path to validate rollback: call approveErasureRequestCore (or the exported
function that triggers the two-step update flow) inside the
env!.withSecurityRulesDisabled block after seeding the request with
seedRequest('req-4', 'pending_review'), configure mockUpdateUser to succeed for
the first call and throw/reject on the second to simulate a write failure, then
assert that the re-disable call was invoked (e.g., check mockUpdateUser mock
calls or the specific re-disable helper was called) and that an error was
thrown/handled; update the test to remove the expect(true).toBe(true)
placeholder and replace it with these actual assertions so the deny rollback
path is exercised and verified.

In `@functions/src/__tests__/callables/request-data-erasure.test.ts`:
- Around line 15-16: Reset the mock state before configuring return values so
call history assertions are deterministic: in the test file's beforeEach, call
mockUpdateUser.mockClear() (or mockUpdateUser.mockReset()) before
mockUpdateUser.mockResolvedValue(undefined), and do the same for any other mock
functions used in this file so not.toHaveBeenCalled() checks are reliable.
- Around line 6-9: Replace the current non-hoisted mockUpdateUser with a hoisted
factory so per-test alterations like mockRejectedValueOnce don't leak across
tests: use vi.hoisted to create and export mockUpdateUser (e.g., const {
mockUpdateUser } = vi.hoisted(() => ({ mockUpdateUser: vi.fn() }))) and keep the
vi.mock('firebase-admin/auth', ...) implementation returning getAuth: () => ({
updateUser: mockUpdateUser }); also ensure tests that call
mockUpdateUser.mockRejectedValueOnce(...) rely on this hoisted mock so each test
gets isolated behavior.

In `@functions/src/__tests__/callables/set-erasure-legal-hold.test.ts`:
- Around line 82-89: The test title claims it covers "completed or denied" but
only sets up a 'completed' request; either rename the test to reflect only the
completed case or add the denied variant: inside the same it-block (or a new
it-block) create another document in the erasure_requests collection (e.g. doc
'req-4') with status: 'denied' (mirror the existing await
db.collection('erasure_requests').doc('req-3').set(...) pattern), then invoke
the same callable/expect assertion used for req-3 so the test asserts the
function throws the same failed-precondition for both statuses; update the
it(...) string if you choose to only test completed.

In `@functions/src/__tests__/rules/erasure-requests.rules.test.ts`:
- Around line 92-101: The test "superadmin can read any request" currently seeds
only the erasure_requests doc but omits the active_accounts precondition
required by the rule (isSuperadmin() && isActivePrivileged()); update the test
to, inside the env!.withSecurityRulesDisabled block before creating the
erasure_requests doc, also setDoc(doc(ctx.firestore(), 'active_accounts',
'uid-admin'), { accountStatus: 'active' }) so the
authenticatedContext('uid-admin', superadminToken) meets both isSuperadmin() and
isActivePrivileged() checks and the assertSucceeds(getDoc(...)) remains valid.

In `@functions/src/__tests__/triggers/erasure-sweep.test.ts`:
- Around line 66-70: Reset the shared mock call history at the start of each
test in the beforeEach block by calling the appropriate Jest reset method (e.g.,
mockClear() or mockReset()) on mockUpdateUser, mockDeleteUser, and mockGetFiles
so prior test calls don't affect later assertions; locate the beforeEach setup
and add mockUpdateUser.mockClear(), mockDeleteUser.mockClear(), and
mockGetFiles.mockClear() (or mockReset() if you also need to clear
implementations) before setting mockResolvedValue.

In `@functions/src/callables/approve-erasure-request.ts`:
- Around line 79-81: The empty catch on auth.updateUser(citizenUid, { disabled:
true }) swallows rollback failures; replace it with a catch that captures the
error (e.g., assign to a local rollbackError variable), log the failure with
context (include citizenUid and the error) and preserve original-error
precedence by not masking the primary error — instead attach the rollbackError
to the primary error object (or store it for inclusion in the response) so
recovery evidence is recorded; reference auth.updateUser and citizenUid and add
a rollbackError variable and a logger call in the catch block.
- Around line 55-76: The deny path checks requestRef.status outside the
transaction and then updates it inside db.runTransaction, which allows a race
where a concurrent review can overwrite this deny; to fix, perform the status
gating inside the transaction by using tx.get(requestRef) at the start of the
db.runTransaction callback, verify the doc exists and its status is still
'pending_review' (throw HttpsError('failed-precondition', ... ) from inside the
tx if not), then call tx.update(requestRef, {...}) and tx.delete(sentinelRef);
also move the auth.updateUser(citizenUid, { disabled: false }) call to after the
transaction completes successfully so you don't re-enable the user if the
transaction rolls back (use requestRef, db.runTransaction, tx.get, tx.update,
tx.delete, sentinelRef and auth.updateUser to locate the changes).

In `@functions/src/callables/request-data-erasure.ts`:
- Around line 34-36: The catch block that currently calls
Promise.allSettled([requestRef.delete(), sentinelRef.delete()]) and then throws
new HttpsError('internal','auth_disable_failed') must inspect the allSettled
results and handle failures explicitly: after awaiting Promise.allSettled, check
each result (for requestRef.delete() and sentinelRef.delete()) and if any
settled as "rejected" log the error and throw a distinct HttpsError (or include
details) so callers know rollback failed; ensure you reference the specific
operations (requestRef.delete, sentinelRef.delete) and do not swallow their
rejection—fail fast with a clear message and preserve the original auth-disable
error context when rethrowing.

In `@functions/src/callables/set-erasure-legal-hold.ts`:
- Around line 26-39: The read-then-update must be done inside a Firestore
transaction to make the legal-hold state transition atomic: replace the separate
ref.get() and ref.update() with db.runTransaction(async (tx) => { const snap =
await tx.get(ref); if (!snap.exists) throw ...; const status =
snap.data()?.status as string; if (TERMINAL_STATUSES.has(status)) throw ...;
tx.update(ref, { legalHold: data.hold, legalHoldReason: data.reason,
legalHoldSetBy: actor.uid }); }); so the existence/status check and the update
on the 'erasure_requests' doc referenced by ref are performed atomically.

In `@functions/src/triggers/erasure-sweep.ts`:
- Around line 28-59: The current two-step "query then update" for claiming an
erasure request is race-prone; change it to an atomic transactional claim:
inside a Firestore transaction, re-read the chosen document (candidate.ref) and
verify its status is still either 'approved_pending_anonymization' (and
legalHold !== true) or stale 'executing' with executionStartedAt < now() -
STALE_EXECUTING_MS, then set status to 'executing' and write sweepRunId and
executionStartedAt; if the verification fails abort the transaction and let the
function return without claiming. Use the same symbols (readySnap, staleSnap,
candidate, sweepRunId, executionStartedAt, STALE_EXECUTING_MS) so the logic
locates the document and updates it only within the transaction.
- Around line 185-190: The deletion loop in erasure-sweep.ts currently lists the
entire report_media/ bucket for each reportId and uses
file.name.includes(reportId), which risks substring collisions and inefficiency;
change the logic in the for (const reportId of reportIds) loop to list only
files for that specific report by using a report-scoped prefix (e.g., derive a
prefix like `report_media/${reportId}/` or otherwise construct the exact object
key pattern) when calling input.storage.bucket().getFiles, and match filenames
with exact equality or startsWith rather than includes(reportId) before calling
file.delete(); update the code paths using getFiles, file.delete, and the
reportId variable accordingly to avoid full-bucket scans and accidental
deletions.

In `@functions/src/triggers/retention-sweep.ts`:
- Around line 79-82: The current code uses substring matching (files.filter((f)
=> f.name.includes(doc.id))) which can remove unrelated files and is
inefficient; instead construct deterministic storage paths and list/delete by
exact prefix for that document (e.g., use getFiles({ prefix:
`report_media/${doc.id}` }) or delete known file paths via
bucket.file(path).delete()), replace the files.filter usage with a narrow
getFiles call and call file.delete only on the returned files; update references
to input.storage.bucket(), getFiles, file.delete and doc.id accordingly.
- Around line 120-146: The hard-delete loop (iterating over toDelete from the
retentionHardDeleteEligibleAt query) must skip reports belonging to citizens
with active erasure requests: before deleting, derive the report owner UID (use
the same field your anonymization code uses, e.g. doc.get('citizenUid') ??
doc.get('ownerUid')) and if activeErasureUids.has(uid) return/continue without
deleting; keep the rest of the logic (deleting report_private, report_contacts,
doc.ref.delete, and writing retention_audit_log) unchanged and log/skipping
counts accordingly. Ensure you reference activeErasureUids, toDelete, and the
retentionHardDeleteEligibleAt query when implementing the check.

In `@infra/firebase/firestore.rules`:
- Around line 514-515: The create rule currently only checks ownership; update
the allow create rule for this collection to validate the incoming document
schema and that the payload's citizenUid matches the path UID: check
request.resource.data.citizenUid == citizenUid, restrict allowed field names
(e.g., only ["citizenUid","erasure_active","createdAt", ...] as appropriate)
using request.resource.data.keys().hasOnly(...), and validate types/format for
erasure_active (e.g., boolean or timestamp) and any required fields via
request.resource.data.field is <type> checks so malicious or malformed create
payloads are rejected.
- Around line 499-501: The create rule for erasure_requests currently only
checks isAuthed(), request.resource.data.citizenUid and status ==
'pending_review', so tighten it by explicitly validating the full request shape:
require the exact set of keys (e.g., citizenUid, status, createdAt, reason or
whatever fields your model mandates) using request.resource.data.keys() and
ensure the keys.size() equals the expected number, validate each field's type
and content (e.g., request.resource.data.citizenUid == request.auth.uid, status
== 'pending_review', createdAt is a timestamp, reason is a string of acceptable
length), and reject any extra fields by asserting keys().size() matches the
required list; reference the isAuthed() helper and the existing
request.resource.data.citizenUid/status checks when adding these validations in
the erasure_requests create rule.

In `@infra/firebase/storage.rules`:
- Around line 22-26: The current rule grants any authenticated citizen broad
access because of the unscoped isCitizen() fallback in the
/report_media/{municipalityId}/{reportId}/{filename} match; remove that unscoped
isCitizen() clause and replace it with an ownership-scoped check (for example,
require isCitizen() && resource.data.ownerUid == request.auth.uid or call a
helper like isReportOwner(municipalityId, reportId) that verifies the requesting
uid matches the report’s owner). Ensure the new predicate references the
existing path variables (municipalityId, reportId, filename) or resource
metadata so citizens can only read media they own, leaving the isMuniAdmin(),
myMunicipality(), isSuperadmin() && municipalityId in permittedMunis() checks
intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b15c8e43-dfba-4aaf-ad5e-a53dd0b15353

📥 Commits

Reviewing files that changed from the base of the PR and between d0e54d0 and b986bfa.

📒 Files selected for processing (22)
  • apps/citizen-pwa/src/__tests__/erasure.test.ts
  • apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx
  • apps/citizen-pwa/src/components/DeleteAccountFlow.tsx
  • apps/citizen-pwa/src/components/GoodbyeScreen.tsx
  • apps/citizen-pwa/src/routes.tsx
  • apps/citizen-pwa/src/services/erasure.ts
  • docs/learnings.md
  • docs/progress.md
  • functions/src/__tests__/callables/approve-erasure-request.test.ts
  • functions/src/__tests__/callables/request-data-erasure.test.ts
  • functions/src/__tests__/callables/set-erasure-legal-hold.test.ts
  • functions/src/__tests__/rules/erasure-requests.rules.test.ts
  • functions/src/__tests__/triggers/erasure-sweep.test.ts
  • functions/src/__tests__/triggers/retention-sweep.test.ts
  • functions/src/callables/approve-erasure-request.ts
  • functions/src/callables/request-data-erasure.ts
  • functions/src/callables/set-erasure-legal-hold.ts
  • functions/src/index.ts
  • functions/src/triggers/erasure-sweep.ts
  • functions/src/triggers/retention-sweep.ts
  • infra/firebase/firestore.rules
  • infra/firebase/storage.rules

Comment thread apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx Outdated
Comment on lines +22 to +25
} catch {
setError('Something went wrong. Your account has not been deleted. Please try again.')
setStep('confirm')
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle idempotent erasure conflicts separately from generic failures.

All failures are collapsed to one generic error, so an already-exists erasure request is treated as hard failure instead of a recoverable/expected state.

Suggested fix
-      } catch {
-        setError('Something went wrong. Your account has not been deleted. Please try again.')
-        setStep('confirm')
+      } catch (err: unknown) {
+        const code =
+          typeof err === 'object' && err !== null && 'code' in err
+            ? String((err as { code?: unknown }).code)
+            : ''
+        if (code === 'already-exists') {
+          onGoodbye()
+          return
+        }
+        setError('Something went wrong. Your account has not been deleted. Please try again.')
+        setStep('confirm')
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch {
setError('Something went wrong. Your account has not been deleted. Please try again.')
setStep('confirm')
}
} catch (err: unknown) {
const code =
typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: unknown }).code)
: ''
if (code === 'already-exists') {
onGoodbye()
return
}
setError('Something went wrong. Your account has not been deleted. Please try again.')
setStep('confirm')
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/components/DeleteAccountFlow.tsx` around lines 22 - 25,
The catch block in DeleteAccountFlow.tsx currently swallows all errors and sets
a generic failure via setError and setStep('confirm'); change the catch to
capture the error object (catch (err)) and branch on the erasure-conflict
sentinel (e.g. err.code === 'already-exists' or
err.message.includes('already-exists')) so that when an idempotent
"already-exists" erasure response occurs you treat it as a recoverable/expected
state (advance the flow or show a success/info message) instead of marking it as
a hard failure; keep the existing generic setError/setStep('confirm') behavior
for other errors.

Comment thread apps/citizen-pwa/src/components/DeleteAccountFlow.tsx
Comment thread apps/citizen-pwa/src/services/erasure.ts
Comment thread functions/src/__tests__/callables/approve-erasure-request.test.ts Outdated
Comment thread functions/src/triggers/retention-sweep.ts Outdated
Comment on lines +120 to +146
const toDelete = await input.db
.collection('reports')
.where('retentionHardDeleteEligibleAt', '<', now())
.get()

for (const doc of toDelete.docs) {
try {
await input.db.collection('report_private').doc(doc.id).delete()
await input.db.collection('report_contacts').doc(doc.id).delete()
await doc.ref.delete()

// Write audit log outside the deleted document
await input.db.collection('retention_audit_log').add({
reportId: doc.id,
retentionDeletedAt: now(),
reason: 'retention_policy',
})
result.hardDeleted++
} catch (err: unknown) {
log({
severity: 'ERROR',
code: 'RETENTION_DELETE_FAILED',
message: `retention delete failed for ${doc.id}: ${String(err)}`,
data: { reportId: doc.id, error: String(err) },
})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hard-delete path should also skip citizens with active erasure requests.

Right now only anonymization respects activeErasureUids; hard-delete can still run for actively erasing users and conflict with the erasure sweep.

Proposed fix
   for (const doc of toDelete.docs) {
+    const submittedBy = doc.data().submittedBy as string | undefined
+    if (submittedBy && activeErasureUids.has(submittedBy)) continue
     try {
       await input.db.collection('report_private').doc(doc.id).delete()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/triggers/retention-sweep.ts` around lines 120 - 146, The
hard-delete loop (iterating over toDelete from the retentionHardDeleteEligibleAt
query) must skip reports belonging to citizens with active erasure requests:
before deleting, derive the report owner UID (use the same field your
anonymization code uses, e.g. doc.get('citizenUid') ?? doc.get('ownerUid')) and
if activeErasureUids.has(uid) return/continue without deleting; keep the rest of
the logic (deleting report_private, report_contacts, doc.ref.delete, and writing
retention_audit_log) unchanged and log/skipping counts accordingly. Ensure you
reference activeErasureUids, toDelete, and the retentionHardDeleteEligibleAt
query when implementing the check.

Comment thread infra/firebase/firestore.rules Outdated
Comment thread infra/firebase/firestore.rules Outdated
Comment on lines +514 to +515
allow create: if isAuthed() && request.auth.uid == citizenUid;
allow read: if (isAuthed() && request.auth.uid == citizenUid)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Constrain erasure_active create payload to the path UID and expected schema.

allow create checks auth/path ownership only; it does not validate document structure or citizenUid consistency.

Suggested fix
-      allow create: if isAuthed() && request.auth.uid == citizenUid;
+      allow create: if isAuthed()
+                    && request.auth.uid == citizenUid
+                    && request.resource.data.keys().hasOnly(['citizenUid', 'createdAt'])
+                    && request.resource.data.citizenUid == citizenUid
+                    && request.resource.data.createdAt is number;

As per coding guidelines: "Validate external input at boundaries and never swallow errors with empty catch blocks; assume external input is malicious or broken".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
allow create: if isAuthed() && request.auth.uid == citizenUid;
allow read: if (isAuthed() && request.auth.uid == citizenUid)
allow create: if isAuthed()
&& request.auth.uid == citizenUid
&& request.resource.data.keys().hasOnly(['citizenUid', 'createdAt'])
&& request.resource.data.citizenUid == citizenUid
&& request.resource.data.createdAt is number;
allow read: if (isAuthed() && request.auth.uid == citizenUid)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/firebase/firestore.rules` around lines 514 - 515, The create rule
currently only checks ownership; update the allow create rule for this
collection to validate the incoming document schema and that the payload's
citizenUid matches the path UID: check request.resource.data.citizenUid ==
citizenUid, restrict allowed field names (e.g., only
["citizenUid","erasure_active","createdAt", ...] as appropriate) using
request.resource.data.keys().hasOnly(...), and validate types/format for
erasure_active (e.g., boolean or timestamp) and any required fields via
request.resource.data.field is <type> checks so malicious or malformed create
payloads are rejected.

Comment on lines 22 to 26
match /report_media/{municipalityId}/{reportId}/{filename} {
allow read: if (isMuniAdmin() && myMunicipality() == municipalityId)
|| (isSuperadmin() && municipalityId in permittedMunis());
|| (isSuperadmin() && municipalityId in permittedMunis())
|| isCitizen(); // citizens read their own report media
allow write: if false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove the unscoped citizen read fallback.

isCitizen() here grants every authenticated citizen read access to every report_media object. That is a cross-account privacy leak unless you have an ownership predicate tied to the object path or metadata.

🔒 Suggested fix
     match /report_media/{municipalityId}/{reportId}/{filename} {
       allow read: if (isMuniAdmin() && myMunicipality() == municipalityId)
                   || (isSuperadmin() && municipalityId in permittedMunis())
-                  || isCitizen();   // citizens read their own report media
       allow write: if false;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/firebase/storage.rules` around lines 22 - 26, The current rule grants
any authenticated citizen broad access because of the unscoped isCitizen()
fallback in the /report_media/{municipalityId}/{reportId}/{filename} match;
remove that unscoped isCitizen() clause and replace it with an ownership-scoped
check (for example, require isCitizen() && resource.data.ownerUid ==
request.auth.uid or call a helper like isReportOwner(municipalityId, reportId)
that verifies the requesting uid matches the report’s owner). Ensure the new
predicate references the existing path variables (municipalityId, reportId,
filename) or resource metadata so citizens can only read media they own, leaving
the isMuniAdmin(), myMunicipality(), isSuperadmin() && municipalityId in
permittedMunis() checks intact.

- PWA erasure.ts: treat already-exists as success, always sign out
- DeleteAccountFlow: reset typed/error on dialog dismiss, add already-exists test
- Test mock hygiene: vi.hoisted + mockReset in beforeEach across test files
- approve-erasure-request: log rollback failures instead of swallowing
- request-data-erasure: inspect allSettled results and log rollback failures
- set-erasure-legal-hold: wrap read-update in runTransaction for atomicity
- retention-sweep: skip hard-delete for reports with active erasure UIDs
- firestore.rules: add field allowlists + type checks for erasure_requests/erasure_active create
- storage.rules: add architectural note on unscoped citizen read
- approve-erasure-request.test: replace placeholder with real rollback test
@Exc1D Exc1D merged commit d8fba40 into main Apr 29, 2026
14 checks passed
@Exc1D Exc1D deleted the feat/phase8c-erasure branch April 29, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant