diff --git a/apps/citizen-pwa/src/__tests__/erasure.test.ts b/apps/citizen-pwa/src/__tests__/erasure.test.ts new file mode 100644 index 00000000..3655f6dc --- /dev/null +++ b/apps/citizen-pwa/src/__tests__/erasure.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockCallable, mockSignOut } = vi.hoisted(() => ({ + mockCallable: vi.fn(), + mockSignOut: vi.fn(), +})) +vi.mock('../services/firebase.js', () => ({ + fns: vi.fn(), + auth: vi.fn(), + httpsCallable: () => mockCallable, +})) +vi.mock('firebase/auth', () => ({ signOut: mockSignOut })) + +import { requestDataErasureAndSignOut } from '../services/erasure.js' + +beforeEach(() => { + mockCallable.mockClear() + mockSignOut.mockClear() + mockCallable.mockResolvedValue({ data: {} }) + mockSignOut.mockResolvedValue(undefined) +}) + +describe('requestDataErasureAndSignOut', () => { + it('calls requestDataErasure callable then signOut', async () => { + await requestDataErasureAndSignOut() + expect(mockCallable).toHaveBeenCalledWith({}) + expect(mockSignOut).toHaveBeenCalled() + }) + + it('throws if callable fails without calling signOut', async () => { + mockCallable.mockRejectedValueOnce({ code: 'internal' }) + await expect(requestDataErasureAndSignOut()).rejects.toMatchObject({ code: 'internal' }) + expect(mockSignOut).not.toHaveBeenCalled() + }) + + it('still calls signOut if already-exists error (prior request pending)', async () => { + mockCallable.mockRejectedValueOnce({ code: 'already-exists' }) + // already-exists means a prior request is in-flight; treat as success + // so the user is signed out and the UI transitions to goodbye + await requestDataErasureAndSignOut() + expect(mockSignOut).toHaveBeenCalled() + }) +}) diff --git a/apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx b/apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx new file mode 100644 index 00000000..9e7755ca --- /dev/null +++ b/apps/citizen-pwa/src/components/DeleteAccountFlow.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { DeleteAccountFlow } from './DeleteAccountFlow.js' + +const { mockErasure } = vi.hoisted(() => ({ mockErasure: vi.fn() })) +vi.mock('../services/erasure.js', () => ({ + requestDataErasureAndSignOut: (): Promise => mockErasure() as Promise, +})) + +beforeEach(() => { + mockErasure.mockReset() +}) + +describe('DeleteAccountFlow', () => { + it('renders trigger button', () => { + render() + expect(screen.getByRole('button', { name: /delete my account/i })).toBeDefined() + }) + + it('shows step-1 warning modal on trigger click', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete my account/i })) + expect(screen.getByText(/delete your account/i)).toBeDefined() + expect(screen.getByRole('button', { name: /yes, delete my account/i })).toBeDefined() + }) + + it('shows step-2 typing gate after step-1 confirmation', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete my account/i })) + await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) + expect(screen.getByPlaceholderText(/type delete/i)).toBeDefined() + }) + + it('submit is disabled until user types DELETE', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete my account/i })) + await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) + const confirmBtn = screen.getByRole('button', { name: /confirm deletion/i }) + expect(confirmBtn.hasAttribute('disabled')).toBe(true) + await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') + expect(confirmBtn.hasAttribute('disabled')).toBe(false) + }) + + it('calls erasure service and onGoodbye on successful submission', async () => { + mockErasure.mockResolvedValueOnce(undefined) + const onGoodbye = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete my account/i })) + await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) + await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') + await user.click(screen.getByRole('button', { name: /confirm deletion/i })) + await waitFor(() => { + expect(onGoodbye).toHaveBeenCalled() + }) + }) + + it('shows error message on callable failure', async () => { + mockErasure.mockRejectedValueOnce({ code: 'internal' }) + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete my account/i })) + await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) + await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') + await user.click(screen.getByRole('button', { name: /confirm deletion/i })) + await waitFor(() => { + expect(screen.getByRole('alert')).toBeDefined() + }) + }) + + it('calls onGoodbye when callable returns already-exists', async () => { + // Service swallows already-exists and resolves so user is signed out + mockErasure.mockResolvedValueOnce(undefined) + const onGoodbye = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /delete my account/i })) + await user.click(screen.getByRole('button', { name: /yes, delete my account/i })) + await user.type(screen.getByPlaceholderText(/type delete/i), 'DELETE') + await user.click(screen.getByRole('button', { name: /confirm deletion/i })) + await waitFor(() => { + expect(onGoodbye).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/citizen-pwa/src/components/DeleteAccountFlow.tsx b/apps/citizen-pwa/src/components/DeleteAccountFlow.tsx new file mode 100644 index 00000000..b587fbbc --- /dev/null +++ b/apps/citizen-pwa/src/components/DeleteAccountFlow.tsx @@ -0,0 +1,95 @@ +import { useState, useCallback } from 'react' +import { requestDataErasureAndSignOut } from '../services/erasure.js' + +interface Props { + onGoodbye: () => void +} + +type Step = 'idle' | 'warn' | 'confirm' | 'submitting' + +export function DeleteAccountFlow({ onGoodbye }: Props) { + const [step, setStep] = useState('idle') + const [typed, setTyped] = useState('') + const [error, setError] = useState(null) + + const handleConfirm = useCallback(() => { + void (async () => { + setStep('submitting') + setError(null) + try { + await requestDataErasureAndSignOut() + onGoodbye() + } catch { + setError('Something went wrong. Your account has not been deleted. Please try again.') + setStep('confirm') + } + })() + }, [onGoodbye]) + + const goIdle = useCallback(() => { + setStep('idle') + setTyped('') + setError(null) + }, []) + + if (step === 'idle') { + return ( + + ) + } + + if (step === 'warn') { + return ( +
+

Delete your account?

+

This will permanently:

+
    +
  • Remove your name, contact info, and account
  • +
  • Anonymize your reports (they remain as public record)
  • +
  • Sign you out immediately
  • +
+

This cannot be undone. Your request will be reviewed before deletion is complete.

+ + +
+ ) + } + + return ( +
+

Are you sure?

+ + { + setTyped(e.target.value) + }} + autoComplete="off" + /> + {error &&

{error}

} + + +
+ ) +} diff --git a/apps/citizen-pwa/src/components/GoodbyeScreen.tsx b/apps/citizen-pwa/src/components/GoodbyeScreen.tsx new file mode 100644 index 00000000..238fa16f --- /dev/null +++ b/apps/citizen-pwa/src/components/GoodbyeScreen.tsx @@ -0,0 +1,13 @@ +export function GoodbyeScreen() { + return ( +
+
+

Request submitted

+

+ Your deletion request has been submitted. You have been signed out. You will not receive + further notifications. +

+
+
+ ) +} diff --git a/apps/citizen-pwa/src/routes.tsx b/apps/citizen-pwa/src/routes.tsx index e085268f..24ce000f 100644 --- a/apps/citizen-pwa/src/routes.tsx +++ b/apps/citizen-pwa/src/routes.tsx @@ -1,10 +1,12 @@ -import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { createBrowserRouter, RouterProvider, useNavigate } from 'react-router-dom' import { CitizenShell } from './components/CitizenShell.js' import { MapTab } from './components/MapTab/index.js' import { SubmitReportForm } from './components/SubmitReportForm/index.js' import { ReceiptScreen } from './components/ReceiptScreen.js' import { LookupScreen } from './components/LookupScreen.js' import { TrackingScreen } from './components/TrackingScreen.js' +import { GoodbyeScreen } from './components/GoodbyeScreen.js' +import { DeleteAccountFlow } from './components/DeleteAccountFlow.js' function StubTab({ label }: { label: string }) { return ( @@ -14,6 +16,19 @@ function StubTab({ label }: { label: string }) { ) } +function ProfileTab() { + const navigate = useNavigate() + return ( +
+

Profile

+
+

Privacy

+ void navigate('/goodbye')} /> +
+
+ ) +} + const router = createBrowserRouter([ { path: '/', @@ -53,12 +68,13 @@ const router = createBrowserRouter([ path: '/profile', element: ( - + ), }, { path: '/receipt', element: }, { path: '/lookup', element: }, + { path: '/goodbye', element: , handle: { hideBottomNav: true } }, ]) export function AppRoutes() { diff --git a/apps/citizen-pwa/src/services/erasure.ts b/apps/citizen-pwa/src/services/erasure.ts new file mode 100644 index 00000000..0d66a753 --- /dev/null +++ b/apps/citizen-pwa/src/services/erasure.ts @@ -0,0 +1,12 @@ +import { signOut } from 'firebase/auth' +import { auth, fns, httpsCallable } from './firebase.js' + +export async function requestDataErasureAndSignOut(): Promise { + try { + await httpsCallable(fns(), 'requestDataErasure')({}) + } catch (err: unknown) { + const code = (err as { code?: string }).code + if (code !== 'already-exists') throw err + } + await signOut(auth()) +} diff --git a/docs/learnings.md b/docs/learnings.md index e0bc3320..92313826 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -108,6 +108,16 @@ - `bcryptjs` preferred over `bcrypt` in this repo — pure JS, no native compilation. - `@google-cloud/logging` must be added as explicit dependency when using Cloud Logging API in triggers. +## Phase 8C — RA 10173 Erasure + +- Write Firestore doc before disabling Firebase Auth in erasure callables — if Auth disable fails, doc deletion is the rollback, not Auth re-enable. Simpler invariant with no side effects on write failure. +- `erasure_active/{uid}` sentinel pattern makes the concurrent double-submission race atomic via Firestore transaction. Status checks alone are not sufficient (TOCTOU). +- `erasureSweep` must be sequential (claim one, process, then claim next). Bulk-claiming multiple records lets timeouts strand unclaimed records in `executing` state permanently until the 30-min staleness window. +- Auth hard-delete must be the last step in erasure execution — it is the only non-reversible step and must not precede any Firestore/Storage operation that could fail. +- `retentionSweep` must exclude reports belonging to citizens with active erasure requests via an in-memory UID set. Firestore does not support cross-collection NOT IN filters. +- `retentionHardDeleteEligibleAt` as a queryable field (set at anonymization time + 30 days) avoids the "find a deleted document" problem for the 1-month threshold query. +- `sms_inbox` join is via `senderMsisdnHash` field directly — not via a session ID foreign key. Verify field names against actual schema before implementing SMS nulling steps. + ## Misc - `navigator.clipboard` in happy-dom often needs to be defined as an own property before spying. diff --git a/docs/progress.md b/docs/progress.md index 5feb735e..896858c5 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,25 @@ # Progress -## Current — Phase 7 Provincial Superadmin + NDRRMC + Break-Glass +## Current — Phase 8C — RA 10173 Erasure & Anonymization + +### 8C — RA 10173 Erasure Execution — COMPLETE + +| Task | Status | Notes | +| ------------------------------- | ------- | ---------------------------------------------------------- | +| requestDataErasure callable | ✅ DONE | Sentinel idempotency, write-before-disable order | +| approveErasureRequest deny path | ✅ DONE | Auth re-enable, sentinel delete, transaction gate | +| setErasureLegalHold callable | ✅ DONE | Superadmin + MFA, terminal status guard | +| erasureSweep (15 min) | ✅ DONE | Sequential claim, all-or-nothing, dead-letter path | +| retentionSweep (daily) | ✅ DONE | 1-week anonymize, 1-month hard-delete, active erasure skip | +| Firestore rules | ✅ DONE | erasure_requests, erasure_active, auditLog | +| Storage rules | ✅ DONE | Citizen read-own media | +| Citizen PWA delete-account flow | ✅ DONE | Two-step modal, GoodbyeScreen, /goodbye route | + +**Open production launch blocker:** 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. SMS onboarding path cannot go to production until this is resolved. Requires UID-linkage mechanism at registration time. + +--- + +## Phase 7 Provincial Superadmin + NDRRMC + Break-Glass ### PRE-7 — Audit & Auth Foundation (branch: `feature/phase7-pre`) diff --git a/functions/src/__tests__/callables/approve-erasure-request.test.ts b/functions/src/__tests__/callables/approve-erasure-request.test.ts new file mode 100644 index 00000000..57f307f0 --- /dev/null +++ b/functions/src/__tests__/callables/approve-erasure-request.test.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { approveErasureRequestCore } from '../../callables/approve-erasure-request.js' + +const { mockUpdateUser } = vi.hoisted(() => ({ mockUpdateUser: vi.fn() })) +vi.mock('firebase-admin/auth', () => ({ + getAuth: () => ({ updateUser: mockUpdateUser }), +})) +vi.mock('../../services/audit-stream.js', () => ({ streamAuditEvent: vi.fn() })) + +let env: RulesTestEnvironment | undefined + +async function seedRequest(db: any, id: string, status: string, citizenUid = 'uid-citizen') { + await db + .collection('erasure_requests') + .doc(id) + .set({ citizenUid, status, requestedAt: Date.now() }) + await db.collection('erasure_active').doc(citizenUid).set({ citizenUid, createdAt: Date.now() }) +} + +beforeEach(async () => { + mockUpdateUser.mockReset() + mockUpdateUser.mockResolvedValue(undefined) + env = await initializeTestEnvironment({ + projectId: 'demo-8c-approve', + firestore: { host: 'localhost', port: 8081 }, + }) + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() + for (const col of ['erasure_requests', 'erasure_active']) { + const snap = await db.collection(col).get() + await Promise.all(snap.docs.map((d) => d.ref.delete())) + } + }) +}) + +afterEach(async () => { + await env?.cleanup() +}) + +describe('approveErasureRequestCore', () => { + it('approve sets status to approved_pending_anonymization', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + await seedRequest(db, 'req-1', 'pending_review') + await approveErasureRequestCore( + db, + getAuth(), + { erasureRequestId: 'req-1', approved: true }, + { uid: 'admin-1' }, + ) + + const snap = await db.collection('erasure_requests').doc('req-1').get() + expect(snap.data().status).toBe('approved_pending_anonymization') + expect(mockUpdateUser).not.toHaveBeenCalled() + }) + }) + + it('deny re-enables Auth, deletes sentinel, sets status to denied', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + await seedRequest(db, 'req-2', 'pending_review') + await approveErasureRequestCore( + db, + getAuth(), + { erasureRequestId: 'req-2', approved: false, reason: 'not valid' }, + { uid: 'admin-1' }, + ) + + const snap = await db.collection('erasure_requests').doc('req-2').get() + expect(snap.data().status).toBe('denied') + expect(mockUpdateUser).toHaveBeenCalledWith('uid-citizen', { disabled: false }) + + const sentinel = await db.collection('erasure_active').doc('uid-citizen').get() + expect(sentinel.exists).toBe(false) + }) + }) + + it('throws failed-precondition when status is not pending_review', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + await seedRequest(db, 'req-3', 'approved_pending_anonymization') + await expect( + approveErasureRequestCore( + db, + getAuth(), + { erasureRequestId: 'req-3', approved: true }, + { uid: 'admin-1' }, + ), + ).rejects.toMatchObject({ code: 'failed-precondition' }) + }) + }) + + it('re-disables Auth on deny transaction failure', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + await seedRequest(db, 'req-4', 'pending_review') + mockUpdateUser + .mockResolvedValueOnce(undefined) // re-enable succeeds + .mockResolvedValueOnce(undefined) // re-disable succeeds + // Force the transaction to fail with a non-domain error so rollback is triggered + const originalRunTransaction = db.runTransaction.bind(db) + db.runTransaction = () => Promise.reject(new Error('simulated tx failure')) + await expect( + approveErasureRequestCore( + db, + getAuth(), + { erasureRequestId: 'req-4', approved: false, reason: 'nope' }, + { uid: 'admin-1' }, + ), + ).rejects.toMatchObject({ code: 'internal' }) + expect(mockUpdateUser).toHaveBeenLastCalledWith('uid-citizen', { disabled: true }) + db.runTransaction = originalRunTransaction + }) + }) +}) diff --git a/functions/src/__tests__/callables/request-data-erasure.test.ts b/functions/src/__tests__/callables/request-data-erasure.test.ts new file mode 100644 index 00000000..aa3a2613 --- /dev/null +++ b/functions/src/__tests__/callables/request-data-erasure.test.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { requestDataErasureCore } from '../../callables/request-data-erasure.js' + +const { mockUpdateUser } = vi.hoisted(() => ({ mockUpdateUser: vi.fn() })) +vi.mock('firebase-admin/auth', () => ({ + getAuth: () => ({ updateUser: mockUpdateUser }), +})) +vi.mock('../../services/audit-stream.js', () => ({ streamAuditEvent: vi.fn() })) + +let env: RulesTestEnvironment | undefined + +beforeEach(async () => { + mockUpdateUser.mockReset() + mockUpdateUser.mockResolvedValue(undefined) + env = await initializeTestEnvironment({ + projectId: 'demo-8c-erasure', + firestore: { host: 'localhost', port: 8081 }, + }) + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() + for (const col of ['erasure_requests', 'erasure_active']) { + const snap = await db.collection(col).get() + await Promise.all(snap.docs.map((d) => d.ref.delete())) + } + }) +}) + +afterEach(async () => { + await env?.cleanup() +}) + +describe('requestDataErasureCore', () => { + it('creates erasure_requests doc and sentinel, then disables Auth', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + await requestDataErasureCore(db, getAuth(), { uid: 'user-1' }) + + const reqSnap = await db + .collection('erasure_requests') + .where('citizenUid', '==', 'user-1') + .get() + expect(reqSnap.docs).toHaveLength(1) + expect(reqSnap.docs[0]?.data().status).toBe('pending_review') + expect(reqSnap.docs[0]?.data().legalHold).toBe(false) + + const sentinelSnap = await db.collection('erasure_active').doc('user-1').get() + expect(sentinelSnap.exists).toBe(true) + + expect(mockUpdateUser).toHaveBeenCalledWith('user-1', { disabled: true }) + }) + }) + + it('throws already-exists and does not call Auth if sentinel exists', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await db + .collection('erasure_active') + .doc('user-1') + .set({ citizenUid: 'user-1', createdAt: Date.now() }) + const { getAuth } = await import('firebase-admin/auth') + await expect(requestDataErasureCore(db, getAuth(), { uid: 'user-1' })).rejects.toMatchObject({ + code: 'already-exists', + }) + expect(mockUpdateUser).not.toHaveBeenCalled() + }) + }) + + it('rolls back sentinel and request doc if Auth disable throws', async () => { + mockUpdateUser.mockRejectedValueOnce(new Error('auth error')) + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + await expect(requestDataErasureCore(db, getAuth(), { uid: 'user-2' })).rejects.toMatchObject({ + code: 'internal', + }) + + const sentinelSnap = await db.collection('erasure_active').doc('user-2').get() + expect(sentinelSnap.exists).toBe(false) + + const reqSnap = await db + .collection('erasure_requests') + .where('citizenUid', '==', 'user-2') + .get() + expect(reqSnap.docs).toHaveLength(0) + }) + }) +}) diff --git a/functions/src/__tests__/callables/set-erasure-legal-hold.test.ts b/functions/src/__tests__/callables/set-erasure-legal-hold.test.ts new file mode 100644 index 00000000..01b38b2e --- /dev/null +++ b/functions/src/__tests__/callables/set-erasure-legal-hold.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { setErasureLegalHoldCore } from '../../callables/set-erasure-legal-hold.js' + +vi.mock('../../services/audit-stream.js', () => ({ streamAuditEvent: vi.fn() })) + +let env: RulesTestEnvironment | undefined + +beforeEach(async () => { + env = await initializeTestEnvironment({ + projectId: 'demo-8c-legalhold', + firestore: { host: 'localhost', port: 8081 }, + }) + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() + const snap = await db.collection('erasure_requests').get() + await Promise.all(snap.docs.map((d) => d.ref.delete())) + }) +}) + +afterEach(async () => { + await env?.cleanup() +}) + +describe('setErasureLegalHoldCore', () => { + it('sets legalHold true on an approved_pending_anonymization request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await db.collection('erasure_requests').doc('req-1').set({ + citizenUid: 'uid-1', + status: 'approved_pending_anonymization', + legalHold: false, + requestedAt: Date.now(), + }) + await setErasureLegalHoldCore( + db, + { erasureRequestId: 'req-1', hold: true, reason: 'court order' }, + { uid: 'admin-1' }, + ) + + const snap = await db.collection('erasure_requests').doc('req-1').get() + expect(snap.data().legalHold).toBe(true) + expect(snap.data().legalHoldReason).toBe('court order') + expect(snap.data().legalHoldSetBy).toBe('admin-1') + }) + }) + + it('clears legalHold on an existing hold', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await db.collection('erasure_requests').doc('req-2').set({ + citizenUid: 'uid-2', + status: 'approved_pending_anonymization', + legalHold: true, + requestedAt: Date.now(), + }) + await setErasureLegalHoldCore( + db, + { erasureRequestId: 'req-2', hold: false, reason: 'court lifted' }, + { uid: 'admin-1' }, + ) + + const snap = await db.collection('erasure_requests').doc('req-2').get() + expect(snap.data().legalHold).toBe(false) + }) + }) + + it('throws not-found for missing request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await expect( + setErasureLegalHoldCore( + db, + { erasureRequestId: 'nope', hold: true, reason: 'x' }, + { uid: 'admin-1' }, + ), + ).rejects.toMatchObject({ code: 'not-found' }) + }) + }) + + it('throws failed-precondition on completed request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await db + .collection('erasure_requests') + .doc('req-3') + .set({ citizenUid: 'uid-3', status: 'completed', requestedAt: Date.now() }) + await expect( + setErasureLegalHoldCore( + db, + { erasureRequestId: 'req-3', hold: true, reason: 'x' }, + { uid: 'admin-1' }, + ), + ).rejects.toMatchObject({ code: 'failed-precondition' }) + }) + }) + + it('throws failed-precondition on denied request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + await db + .collection('erasure_requests') + .doc('req-denied') + .set({ citizenUid: 'uid-d', status: 'denied', requestedAt: Date.now() }) + await expect( + setErasureLegalHoldCore( + db, + { erasureRequestId: 'req-denied', hold: true, reason: 'x' }, + { uid: 'admin-1' }, + ), + ).rejects.toMatchObject({ code: 'failed-precondition' }) + }) + }) +}) diff --git a/functions/src/__tests__/rules/erasure-requests.rules.test.ts b/functions/src/__tests__/rules/erasure-requests.rules.test.ts new file mode 100644 index 00000000..a9cf2c5c --- /dev/null +++ b/functions/src/__tests__/rules/erasure-requests.rules.test.ts @@ -0,0 +1,142 @@ +import { + initializeTestEnvironment, + assertSucceeds, + assertFails, + type RulesTestEnvironment, +} from '@firebase/rules-unit-testing' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterAll, beforeAll, beforeEach, describe, it } from 'vitest' +import { doc, setDoc, getDoc, updateDoc } from 'firebase/firestore' + +const RULES_PATH = resolve(import.meta.dirname, '../../../../infra/firebase/firestore.rules') + +let env: RulesTestEnvironment | undefined + +const citizenToken = { role: 'citizen', accountStatus: 'active' } +const superadminToken = { + role: 'provincial_superadmin', + accountStatus: 'active', + mfaVerified: true, +} + +beforeAll(async () => { + env = await initializeTestEnvironment({ + projectId: 'demo-8c-rules', + firestore: { rules: readFileSync(RULES_PATH, 'utf8'), host: 'localhost', port: 8081 }, + }) +}) + +afterAll(async () => { + await env?.cleanup() +}) + +beforeEach(async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() + for (const col of ['erasure_requests', 'erasure_active']) { + const snap = await db.collection(col).get() + await Promise.all(snap.docs.map((d) => d.ref.delete())) + } + }) +}) + +describe('erasure_requests rules', () => { + it('citizen can create their own pending_review request', async () => { + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertSucceeds( + setDoc(doc(db, 'erasure_requests', 'req-1'), { + citizenUid: 'uid-citizen', + status: 'pending_review', + legalHold: false, + requestedAt: Date.now(), + }), + ) + }) + + it('citizen cannot create with executing status', async () => { + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertFails( + setDoc(doc(db, 'erasure_requests', 'req-bad'), { + citizenUid: 'uid-citizen', + status: 'executing', + requestedAt: Date.now(), + }), + ) + }) + + it('citizen can read their own request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'erasure_requests', 'req-mine'), { + citizenUid: 'uid-citizen', + status: 'pending_review', + requestedAt: Date.now(), + }) + }) + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertSucceeds(getDoc(doc(db, 'erasure_requests', 'req-mine'))) + }) + + it('citizen cannot read another citizen request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'erasure_requests', 'req-other'), { + citizenUid: 'uid-other', + status: 'pending_review', + requestedAt: Date.now(), + }) + }) + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertFails(getDoc(doc(db, 'erasure_requests', 'req-other'))) + }) + + it('superadmin can read any request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'erasure_requests', 'req-any'), { + citizenUid: 'uid-citizen', + status: 'pending_review', + requestedAt: Date.now(), + }) + await setDoc(doc(ctx.firestore(), 'active_accounts', 'uid-admin'), { + accountStatus: 'active', + }) + }) + const db = env!.authenticatedContext('uid-admin', superadminToken).firestore() + await assertSucceeds(getDoc(doc(db, 'erasure_requests', 'req-any'))) + }) + + it('citizen cannot update a request after creation', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'erasure_requests', 'req-update'), { + citizenUid: 'uid-citizen', + status: 'pending_review', + requestedAt: Date.now(), + }) + }) + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertFails(updateDoc(doc(db, 'erasure_requests', 'req-update'), { status: 'denied' })) + }) +}) + +describe('erasure_active sentinel rules', () => { + it('citizen can read their own sentinel', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'erasure_active', 'uid-citizen'), { + citizenUid: 'uid-citizen', + createdAt: Date.now(), + }) + }) + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertSucceeds(getDoc(doc(db, 'erasure_active', 'uid-citizen'))) + }) + + it('citizen cannot read another citizen sentinel', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'erasure_active', 'uid-other'), { + citizenUid: 'uid-other', + createdAt: Date.now(), + }) + }) + const db = env!.authenticatedContext('uid-citizen', citizenToken).firestore() + await assertFails(getDoc(doc(db, 'erasure_active', 'uid-other'))) + }) +}) diff --git a/functions/src/__tests__/triggers/erasure-sweep.test.ts b/functions/src/__tests__/triggers/erasure-sweep.test.ts new file mode 100644 index 00000000..3fb1a6a8 --- /dev/null +++ b/functions/src/__tests__/triggers/erasure-sweep.test.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { erasureSweepCore } from '../../triggers/erasure-sweep.js' + +const mockUpdateUser = vi.fn() +const mockDeleteUser = vi.fn() +const mockGetFiles = vi.fn().mockResolvedValue([[]]) +const mockDeleteFile = vi.fn().mockResolvedValue(undefined) + +vi.mock('firebase-admin/auth', () => ({ + getAuth: () => ({ updateUser: mockUpdateUser, deleteUser: mockDeleteUser }), +})) +vi.mock('firebase-admin/storage', () => ({ + getStorage: () => ({ + bucket: () => ({ + getFiles: mockGetFiles, + file: (path: string) => ({ + delete: (): Promise => mockDeleteFile(path) as Promise, + }), + }), + }), +})) +vi.mock('../../services/audit-stream.js', () => ({ streamAuditEvent: vi.fn() })) + +let env: RulesTestEnvironment | undefined + +async function seedApprovedRequest( + db: any, + id: string, + citizenUid: string, + status = 'approved_pending_anonymization', + legalHold = false, +) { + await db.collection('erasure_requests').doc(id).set({ + citizenUid, + status, + legalHold, + requestedAt: Date.now(), + }) + await db.collection('erasure_active').doc(citizenUid).set({ citizenUid, createdAt: Date.now() }) + // Seed a report and report_private for this citizen + await db.collection('reports').doc('report-1').set({ + submittedBy: citizenUid, + verified: false, + municipalityId: 'daet', + status: 'pending', + }) + await db + .collection('report_private') + .doc('report-1') + .set({ + citizenName: 'Juan dela Cruz', + rawPhone: '+639171234567', + gpsExact: { lat: 14.1, lng: 122.9 }, + addressText: '123 Main St', + reportId: 'report-1', + }) + await db.collection('report_contacts').doc('report-1').set({ + email: 'juan@example.com', + phone: '+639171234567', + reportId: 'report-1', + }) +} + +beforeEach(async () => { + mockUpdateUser.mockReset() + mockDeleteUser.mockReset() + mockGetFiles.mockReset() + mockUpdateUser.mockResolvedValue(undefined) + mockDeleteUser.mockResolvedValue(undefined) + mockGetFiles.mockResolvedValue([[]]) + env = await initializeTestEnvironment({ + projectId: 'demo-8c-sweep', + firestore: { host: 'localhost', port: 8081 }, + }) + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() + for (const col of [ + 'erasure_requests', + 'erasure_active', + 'reports', + 'report_private', + 'report_contacts', + ]) { + const snap = await db.collection(col).get() + await Promise.all(snap.docs.map((d) => d.ref.delete())) + } + }) +}) + +afterEach(async () => { + await env?.cleanup() +}) + +describe('erasureSweepCore', () => { + it('anonymizes report fields and deletes Auth on approved request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + const { getStorage } = await import('firebase-admin/storage') + await seedApprovedRequest(db, 'req-1', 'uid-citizen') + + const result = await erasureSweepCore({ db, auth: getAuth(), storage: getStorage() }) + expect(result.processed).toBe(1) + + // Reports anonymized + const reportSnap = await db.collection('reports').doc('report-1').get() + expect(reportSnap.data().submittedBy).toBe('citizen_deleted') + expect(reportSnap.data().mediaRedacted).toBe(true) + + // report_private PII nulled + const privateSnap = await db.collection('report_private').doc('report-1').get() + expect(privateSnap.data().citizenName).toBeNull() + expect(privateSnap.data().rawPhone).toBeNull() + + // report_contacts nulled + const contactSnap = await db.collection('report_contacts').doc('report-1').get() + expect(contactSnap.data().email).toBeNull() + + // Auth deleted (last) + expect(mockDeleteUser).toHaveBeenCalledWith('uid-citizen') + + // Sentinel deleted + const sentinel = await db.collection('erasure_active').doc('uid-citizen').get() + expect(sentinel.exists).toBe(false) + + // Status completed + const reqSnap = await db.collection('erasure_requests').doc('req-1').get() + expect(reqSnap.data().status).toBe('completed') + }) + }) + + it('skips records with legalHold === true', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + const { getStorage } = await import('firebase-admin/storage') + await seedApprovedRequest(db, 'req-held', 'uid-held', 'approved_pending_anonymization', true) + + const result = await erasureSweepCore({ db, auth: getAuth(), storage: getStorage() }) + expect(result.processed).toBe(0) + expect(result.skippedHeld).toBe(1) + expect(mockDeleteUser).not.toHaveBeenCalled() + }) + }) + + it('skips reports with no submittedBy (pseudonymous)', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + const { getStorage } = await import('firebase-admin/storage') + await seedApprovedRequest(db, 'req-pseudo', 'uid-pseudo') + // Add a pseudonymous report (no submittedBy matching uid) + await db.collection('reports').doc('pseudo-report').set({ + municipalityId: 'daet', + status: 'pending', + verified: false, + }) + + const result = await erasureSweepCore({ db, auth: getAuth(), storage: getStorage() }) + expect(result.processed).toBe(1) + // pseudo-report is not touched + const pseudoSnap = await db.collection('reports').doc('pseudo-report').get() + expect(pseudoSnap.data().submittedBy).toBeUndefined() + }) + }) + + it('dead-letters and re-enables Auth on failure, fires CRITICAL alert if re-enable fails', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + const { getStorage } = await import('firebase-admin/storage') + await seedApprovedRequest(db, 'req-fail', 'uid-fail') + // Force Auth delete to throw + mockDeleteUser.mockRejectedValueOnce(new Error('auth error')) + + const result = await erasureSweepCore({ db, auth: getAuth(), storage: getStorage() }) + expect(result.deadLettered).toBe(1) + + const reqSnap = await db.collection('erasure_requests').doc('req-fail').get() + expect(reqSnap.data().status).toBe('dead_lettered') + expect(reqSnap.data().deadLetterReason).toContain('auth error') + // Auth re-enable was attempted + expect(mockUpdateUser).toHaveBeenCalledWith('uid-fail', { disabled: false }) + }) + }) + + it('re-claims stale executing record (>30min) with new sweepRunId', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getAuth } = await import('firebase-admin/auth') + const { getStorage } = await import('firebase-admin/storage') + const staleAt = Date.now() - 31 * 60 * 1000 + await db + .collection('erasure_requests') + .doc('req-stale') + .set({ + citizenUid: 'uid-stale', + status: 'executing', + legalHold: false, + sweepRunId: 'old-run-id', + executionStartedAt: staleAt, + requestedAt: staleAt - 1000, + }) + await db + .collection('erasure_active') + .doc('uid-stale') + .set({ citizenUid: 'uid-stale', createdAt: staleAt }) + + const result = await erasureSweepCore({ db, auth: getAuth(), storage: getStorage() }) + expect(result.processed).toBe(1) + + const reqSnap = await db.collection('erasure_requests').doc('req-stale').get() + expect(reqSnap.data().sweepRunId).not.toBe('old-run-id') + expect(reqSnap.data().status).toBe('completed') + }) + }) +}) diff --git a/functions/src/__tests__/triggers/retention-sweep.test.ts b/functions/src/__tests__/triggers/retention-sweep.test.ts new file mode 100644 index 00000000..0fbb7443 --- /dev/null +++ b/functions/src/__tests__/triggers/retention-sweep.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { initializeTestEnvironment, type RulesTestEnvironment } from '@firebase/rules-unit-testing' +import { retentionSweepCore } from '../../triggers/retention-sweep.js' + +const mockGetFiles = vi.fn().mockResolvedValue([[]]) +const mockDeleteFile = vi.fn().mockResolvedValue(undefined) + +vi.mock('firebase-admin/storage', () => ({ + getStorage: () => ({ + bucket: () => ({ + getFiles: mockGetFiles, + file: (path: string) => ({ + delete: (): Promise => mockDeleteFile(path) as Promise, + }), + }), + }), +})) +vi.mock('../../services/audit-stream.js', () => ({ streamAuditEvent: vi.fn() })) + +let env: RulesTestEnvironment | undefined + +beforeEach(async () => { + mockGetFiles.mockResolvedValue([[]]) + env = await initializeTestEnvironment({ + projectId: 'demo-8c-retention', + firestore: { host: 'localhost', port: 8081 }, + }) + await env.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() + for (const col of ['reports', 'report_private', 'report_contacts', 'erasure_requests']) { + const snap = await db.collection(col).get() + await Promise.all(snap.docs.map((d) => d.ref.delete())) + } + }) +}) + +afterEach(async () => { + await env?.cleanup() +}) + +describe('retentionSweepCore', () => { + it('anonymizes unverified report older than 1 week and sets retentionHardDeleteEligibleAt', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getStorage } = await import('firebase-admin/storage') + const oldSubmittedAt = Date.now() - 8 * 24 * 60 * 60 * 1000 + + await db.collection('reports').doc('r-old').set({ + submittedBy: 'uid-anon', + verified: false, + submittedAt: oldSubmittedAt, + municipalityId: 'daet', + }) + await db + .collection('report_private') + .doc('r-old') + .set({ + citizenName: 'Test User', + rawPhone: '+63917', + gpsExact: { lat: 14.1, lng: 122.9 }, + addressText: '123 St', + reportId: 'r-old', + }) + + const result = await retentionSweepCore({ db, storage: getStorage() }) + expect(result.anonymized).toBe(1) + + const privSnap = await db.collection('report_private').doc('r-old').get() + expect(privSnap.data().citizenName).toBeNull() + + const reportSnap = await db.collection('reports').doc('r-old').get() + expect(reportSnap.data().retentionAnonymizedAt).toBeDefined() + expect(reportSnap.data().retentionHardDeleteEligibleAt).toBeGreaterThan(Date.now()) + }) + }) + + it('skips reports where submittedBy === citizen_deleted', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getStorage } = await import('firebase-admin/storage') + const oldSubmittedAt = Date.now() - 8 * 24 * 60 * 60 * 1000 + + await db.collection('reports').doc('r-erased').set({ + submittedBy: 'citizen_deleted', + verified: false, + submittedAt: oldSubmittedAt, + municipalityId: 'daet', + }) + const result = await retentionSweepCore({ db, storage: getStorage() }) + expect(result.anonymized).toBe(0) + + const reportSnap = await db.collection('reports').doc('r-erased').get() + expect(reportSnap.data().retentionAnonymizedAt).toBeUndefined() + }) + }) + + it('skips reports belonging to citizen with active erasure request', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getStorage } = await import('firebase-admin/storage') + const oldAt = Date.now() - 8 * 24 * 60 * 60 * 1000 + + await db.collection('reports').doc('r-active').set({ + submittedBy: 'uid-active', + verified: false, + submittedAt: oldAt, + municipalityId: 'daet', + }) + // Seed an active erasure request for this citizen + await db.collection('erasure_requests').doc('req-active').set({ + citizenUid: 'uid-active', + status: 'executing', + requestedAt: oldAt, + }) + + const result = await retentionSweepCore({ db, storage: getStorage() }) + expect(result.anonymized).toBe(0) + + // Verify in-memory check excluded the report + const reportSnap = await db.collection('reports').doc('r-active').get() + expect(reportSnap.data().retentionAnonymizedAt).toBeUndefined() + }) + }) + + it('hard-deletes report when retentionHardDeleteEligibleAt is in the past', async () => { + await env!.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore() as any + const { getStorage } = await import('firebase-admin/storage') + const pastEligible = Date.now() - 1000 + + await db + .collection('reports') + .doc('r-delete') + .set({ + submittedBy: 'uid-old', + verified: false, + submittedAt: Date.now() - 40 * 24 * 60 * 60 * 1000, + retentionAnonymizedAt: pastEligible - 30 * 24 * 60 * 60 * 1000, + retentionHardDeleteEligibleAt: pastEligible, + municipalityId: 'daet', + }) + await db.collection('report_private').doc('r-delete').set({ reportId: 'r-delete' }) + await db.collection('report_contacts').doc('r-delete').set({ reportId: 'r-delete' }) + + const result = await retentionSweepCore({ db, storage: getStorage() }) + expect(result.hardDeleted).toBe(1) + + const reportSnap = await db.collection('reports').doc('r-delete').get() + expect(reportSnap.exists).toBe(false) + + const privSnap = await db.collection('report_private').doc('r-delete').get() + expect(privSnap.exists).toBe(false) + }) + }) +}) diff --git a/functions/src/callables/approve-erasure-request.ts b/functions/src/callables/approve-erasure-request.ts index 08a30a08..702a220d 100644 --- a/functions/src/callables/approve-erasure-request.ts +++ b/functions/src/callables/approve-erasure-request.ts @@ -1,5 +1,6 @@ import { onCall, HttpsError } from 'firebase-functions/v2/https' import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { getAuth, type Auth } from 'firebase-admin/auth' import { z } from 'zod' import { requireAuth, requireMfaAuth } from './https-error.js' import { streamAuditEvent } from '../services/audit-stream.js' @@ -12,46 +13,85 @@ const inputSchema = z.object({ export async function approveErasureRequestCore( db: Firestore, + auth: Auth, input: unknown, actor: { uid: string }, ): Promise { const parsed = inputSchema.safeParse(input) if (!parsed.success) { - const issues = parsed.error.issues - let msg = 'invalid_input' - if (issues.length > 0) { - const firstIssue = issues[0] - if (firstIssue) { - msg = firstIssue.message - } - } - throw new HttpsError('invalid-argument', msg) + const firstIssue = parsed.error.issues[0] + throw new HttpsError('invalid-argument', firstIssue?.message ?? 'invalid_input') } const data = parsed.data - await db.runTransaction(async (tx) => { - const doc = await tx.get(db.collection('erasure_requests').doc(data.erasureRequestId)) - if (!doc.exists) { - throw new HttpsError('not-found', 'erasure_request_not_found') - } - const currentStatus = doc.data()?.status - if (currentStatus !== 'pending') { - throw new HttpsError('failed-precondition', 'erasure_already_reviewed') - } - const status = data.approved ? 'approved_pending_anonymization' : 'denied' - tx.update(doc.ref, { - status, - reviewedBy: actor.uid, - reviewedAt: Date.now(), - ...(data.reason ? { reviewReason: data.reason } : {}), + // Transaction gate: read + verify status before writing. + // Prevents concurrent approve+deny both succeeding on 'pending_review'. + const requestRef = db.collection('erasure_requests').doc(data.erasureRequestId) + + if (data.approved) { + await db.runTransaction(async (tx) => { + const snap = await tx.get(requestRef) + 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') + } + tx.update(requestRef, { + status: 'approved_pending_anonymization', + reviewedBy: actor.uid, + reviewedAt: Date.now(), + ...(data.reason ? { reviewReason: data.reason } : {}), + }) }) void streamAuditEvent({ eventType: 'erasure_request_reviewed', actorUid: actor.uid, targetDocumentId: data.erasureRequestId, - metadata: { approved: data.approved }, + metadata: { approved: true }, occurredAt: Date.now(), }) + 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') + const citizenUid = snap.data()?.citizenUid as string + + await auth.updateUser(citizenUid, { disabled: false }) + + try { + await db.runTransaction(async (tx) => { + const fresh = await tx.get(requestRef) + if (!fresh.exists) throw new HttpsError('not-found', 'erasure_request_not_found') + if (fresh.data()?.status !== 'pending_review') { + throw new HttpsError('failed-precondition', 'erasure_already_reviewed') + } + const sentinelRef = db.collection('erasure_active').doc(citizenUid) + tx.update(requestRef, { + status: 'denied', + reviewedBy: actor.uid, + reviewedAt: Date.now(), + ...(data.reason ? { reviewReason: data.reason } : {}), + }) + tx.delete(sentinelRef) + }) + } catch (err: unknown) { + // Re-throw domain errors (not-found, failed-precondition) as-is. + if (err instanceof HttpsError) throw err + // Doc write failed after Auth was re-enabled — re-disable Auth as rollback. + await auth.updateUser(citizenUid, { disabled: true }).catch((rollbackErr: unknown) => { + // Log but don't throw — the original error takes precedence. + console.error('CRITICAL: rollback re-disable failed for', citizenUid, rollbackErr) + }) + throw new HttpsError('internal', 'deny_write_failed') + } + + void streamAuditEvent({ + eventType: 'erasure_request_reviewed', + actorUid: actor.uid, + targetDocumentId: data.erasureRequestId, + metadata: { approved: false }, + occurredAt: Date.now(), }) } @@ -60,6 +100,6 @@ export const approveErasureRequest = onCall( async (request) => { const { uid } = requireAuth(request, ['superadmin']) requireMfaAuth(request) - await approveErasureRequestCore(getFirestore(), request.data, { uid }) + await approveErasureRequestCore(getFirestore(), getAuth(), request.data, { uid }) }, ) diff --git a/functions/src/callables/request-data-erasure.ts b/functions/src/callables/request-data-erasure.ts new file mode 100644 index 00000000..0833789b --- /dev/null +++ b/functions/src/callables/request-data-erasure.ts @@ -0,0 +1,59 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { getAuth, type Auth } from 'firebase-admin/auth' +import { requireAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +export async function requestDataErasureCore( + db: Firestore, + auth: Auth, + actor: { uid: string }, +): Promise { + const sentinelRef = db.collection('erasure_active').doc(actor.uid) + const requestRef = db.collection('erasure_requests').doc() + const now = Date.now() + + // Atomic: create sentinel + request doc. Transaction fails if sentinel exists. + await db.runTransaction(async (tx) => { + const sentinel = await tx.get(sentinelRef) + if (sentinel.exists) { + throw new HttpsError('already-exists', 'erasure_request_already_active') + } + tx.create(sentinelRef, { citizenUid: actor.uid, createdAt: now }) + tx.create(requestRef, { + citizenUid: actor.uid, + status: 'pending_review', + legalHold: false, + requestedAt: now, + }) + }) + + // Disable Auth after successful doc write. Rollback docs if Auth fails. + try { + await auth.updateUser(actor.uid, { disabled: true }) + } catch { + const results = await Promise.allSettled([requestRef.delete(), sentinelRef.delete()]) + for (const r of results) { + if (r.status === 'rejected') { + console.error('CRITICAL: erasure rollback failed for', actor.uid, r.reason) + } + } + throw new HttpsError('internal', 'auth_disable_failed') + } + + void streamAuditEvent({ + eventType: 'erasure_request_submitted', + actorUid: actor.uid, + targetDocumentId: requestRef.id, + metadata: {}, + occurredAt: now, + }) +} + +export const requestDataErasure = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['citizen']) + await requestDataErasureCore(getFirestore(), getAuth(), { uid }) + }, +) diff --git a/functions/src/callables/set-erasure-legal-hold.ts b/functions/src/callables/set-erasure-legal-hold.ts new file mode 100644 index 00000000..b3d22343 --- /dev/null +++ b/functions/src/callables/set-erasure-legal-hold.ts @@ -0,0 +1,60 @@ +import { onCall, HttpsError } from 'firebase-functions/v2/https' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { z } from 'zod' +import { requireAuth, requireMfaAuth } from './https-error.js' +import { streamAuditEvent } from '../services/audit-stream.js' + +const inputSchema = z.object({ + erasureRequestId: z.string().min(1), + hold: z.boolean(), + reason: z.string().min(1).max(1000), +}) + +const TERMINAL_STATUSES = new Set(['completed', 'denied', 'dead_lettered']) + +export async function setErasureLegalHoldCore( + db: Firestore, + input: unknown, + actor: { uid: string }, +): Promise { + const parsed = inputSchema.safeParse(input) + if (!parsed.success) { + throw new HttpsError('invalid-argument', 'invalid_legal_hold_payload') + } + const data = parsed.data + + const ref = db.collection('erasure_requests').doc(data.erasureRequestId) + + await db.runTransaction(async (tx) => { + const snap = await tx.get(ref) + if (!snap.exists) throw new HttpsError('not-found', 'erasure_request_not_found') + + const status = snap.data()?.status as string + if (TERMINAL_STATUSES.has(status)) { + throw new HttpsError('failed-precondition', 'cannot_hold_terminal_request') + } + + tx.update(ref, { + legalHold: data.hold, + legalHoldReason: data.reason, + legalHoldSetBy: actor.uid, + }) + }) + + void streamAuditEvent({ + eventType: data.hold ? 'erasure_legal_hold_set' : 'erasure_legal_hold_cleared', + actorUid: actor.uid, + targetDocumentId: data.erasureRequestId, + metadata: { hold: data.hold, reason: data.reason }, + occurredAt: Date.now(), + }) +} + +export const setErasureLegalHold = onCall( + { region: 'asia-southeast1', enforceAppCheck: true }, + async (request) => { + const { uid } = requireAuth(request, ['superadmin']) + requireMfaAuth(request) + await setErasureLegalHoldCore(getFirestore(), request.data, { uid }) + }, +) diff --git a/functions/src/index.ts b/functions/src/index.ts index 5d38f043..4cea3458 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -149,6 +149,10 @@ export { declareDataIncident } from './callables/declare-data-incident.js' export { recordIncidentResponseEvent } from './callables/record-incident-response-event.js' export { setRetentionExempt } from './callables/set-retention-exempt.js' export { approveErasureRequest } from './callables/approve-erasure-request.js' +export { requestDataErasure } from './callables/request-data-erasure.js' +export { setErasureLegalHold } from './callables/set-erasure-legal-hold.js' +export { erasureSweep } from './triggers/erasure-sweep.js' +export { retentionSweep } from './triggers/retention-sweep.js' export { toggleMutualAidVisibility } from './callables/toggle-mutual-aid-visibility.js' export { upsertProvincialResource, diff --git a/functions/src/triggers/erasure-sweep.ts b/functions/src/triggers/erasure-sweep.ts new file mode 100644 index 00000000..a2aa32bf --- /dev/null +++ b/functions/src/triggers/erasure-sweep.ts @@ -0,0 +1,224 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { getAuth, type Auth } from 'firebase-admin/auth' +import { getStorage, type Storage } from 'firebase-admin/storage' +import { logDimension } from '@bantayog/shared-validators' +import { streamAuditEvent } from '../services/audit-stream.js' + +const log = logDimension('erasureSweep') +const STALE_EXECUTING_MS = 30 * 60 * 1000 + +export interface ErasureSweepInput { + db: Firestore + auth: Auth + storage: Storage + now?: () => number +} + +export interface ErasureSweepResult { + processed: number + skippedHeld: number + deadLettered: number +} + +export async function erasureSweepCore(input: ErasureSweepInput): Promise { + const now = input.now ?? (() => Date.now()) + const result: ErasureSweepResult = { processed: 0, skippedHeld: 0, deadLettered: 0 } + + // Sequential claim: fetch one ready record (or one stale executing record). + const readySnap = await input.db + .collection('erasure_requests') + .where('status', '==', 'approved_pending_anonymization') + .where('legalHold', '!=', true) + .limit(1) + .get() + + const staleSnap = await input.db + .collection('erasure_requests') + .where('status', '==', 'executing') + .where('executionStartedAt', '<', now() - STALE_EXECUTING_MS) + .limit(1) + .get() + + // Count held records for system_health observability + const heldSnap = await input.db + .collection('erasure_requests') + .where('status', '==', 'approved_pending_anonymization') + .where('legalHold', '==', true) + .get() + result.skippedHeld = heldSnap.size + + const candidate = readySnap.docs[0] ?? staleSnap.docs[0] + if (!candidate) return result + + const sweepRunId = crypto.randomUUID() + const citizenUid = candidate.data().citizenUid as string + + // Claim the record transactionally to prevent double-processing by concurrent sweeps. + await input.db.runTransaction(async (tx) => { + const fresh = await tx.get(candidate.ref) + const status = fresh.data()?.status as string + const legalHold = fresh.data()?.legalHold as boolean | undefined + const executionStartedAt = fresh.data()?.executionStartedAt as number | undefined + const isReady = status === 'approved_pending_anonymization' && legalHold !== true + const isStale = + status === 'executing' && + executionStartedAt != null && + executionStartedAt < now() - STALE_EXECUTING_MS + if (!isReady && !isStale) { + throw new Error('claim_lost_race') + } + tx.update(candidate.ref, { status: 'executing', sweepRunId, executionStartedAt: now() }) + }) + + try { + await executeErasure(input, citizenUid, candidate.ref.id) + await candidate.ref.update({ status: 'completed', completedAt: now() }) + + // Delete sentinel after Auth is gone + await input.db.collection('erasure_active').doc(citizenUid).delete() + + void streamAuditEvent({ + eventType: 'erasure_completed', + actorUid: 'system', + targetDocumentId: candidate.ref.id, + metadata: { citizenUid }, + occurredAt: now(), + }) + result.processed++ + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err) + log({ + severity: 'ERROR', + code: 'ERASURE_SWEEP_FAILURE', + message: `erasure sweep failed for ${citizenUid}: ${reason}`, + data: { citizenUid, reason }, + }) + + // Re-enable Auth — citizen must not be permanently locked out by a sweep failure + try { + await input.auth.updateUser(citizenUid, { disabled: false }) + } catch (reEnableErr: unknown) { + // CRITICAL: citizen is locked out and sweep failed — manual intervention required + log({ + severity: 'CRITICAL', + code: 'ERASURE_SWEEP_AUTH_REENABLE_FAILED', + message: `Auth re-enable failed after erasure sweep failure for ${citizenUid}`, + data: { + citizenUid, + originalError: reason, + reEnableError: reEnableErr instanceof Error ? reEnableErr.message : String(reEnableErr), + }, + }) + } + + await candidate.ref.update({ + status: 'dead_lettered', + deadLetterReason: reason, + deadLetteredAt: now(), + }) + + void streamAuditEvent({ + eventType: 'erasure_request_dead_lettered_with_auth_unblocked', + actorUid: 'system', + targetDocumentId: candidate.ref.id, + metadata: { citizenUid, reason }, + occurredAt: now(), + }) + result.deadLettered++ + } + + return result +} + +async function executeErasure( + input: ErasureSweepInput, + citizenUid: string, + requestId: string, +): Promise { + const db = input.db + + // Step 1: Collect report IDs + const reportsSnap = await db.collection('reports').where('submittedBy', '==', citizenUid).get() + const reportIds = reportsSnap.docs.map((d) => d.id) + + // Step 2: Read report_private BEFORE nulling — extract senderMsisdnHashes + const msisdnHashes = new Set() + for (const reportId of reportIds) { + const privateSnap = await db.collection('report_private').doc(reportId).get() + const hash = privateSnap.data()?.senderMsisdnHash as string | undefined + if (hash) msisdnHashes.add(hash) + } + + // Step 3: Anonymize reports + for (const reportId of reportIds) { + await db.collection('reports').doc(reportId).update({ + submittedBy: 'citizen_deleted', + mediaRedacted: true, + }) + } + + // Step 4: Null report_private PII fields + for (const reportId of reportIds) { + await db.collection('report_private').doc(reportId).update({ + citizenName: null, + rawPhone: null, + gpsExact: null, + addressText: null, + }) + } + + // Step 5: Null report_contacts content + for (const reportId of reportIds) { + const contactSnap = await db.collection('report_contacts').doc(reportId).get() + if (contactSnap.exists) { + const nulled: Record = {} + for (const key of Object.keys(contactSnap.data() ?? {})) { + if (key !== 'reportId') nulled[key] = null + } + await db.collection('report_contacts').doc(reportId).update(nulled) + } + } + + // Step 6: Null sms_sessions by senderMsisdnHash + for (const hash of msisdnHashes) { + const sessSnap = await db.collection('sms_sessions').where('senderMsisdnHash', '==', hash).get() + for (const sess of sessSnap.docs) { + await sess.ref.update({ senderMsisdnHash: null, msisdn: null }) + } + } + + // Step 7: Null sms_inbox by senderMsisdnHash + for (const hash of msisdnHashes) { + const inboxSnap = await db.collection('sms_inbox').where('senderMsisdnHash', '==', hash).get() + for (const msg of inboxSnap.docs) { + await msg.ref.update({ senderMsisdnHash: null, msisdn: null, rawBody: null }) + } + } + + // 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/${reportId}/` }) + for (const file of files) { + await file.delete() + } + } + + // Step 9: Hard-delete Firebase Auth account — LAST, non-reversible + await input.auth.deleteUser(citizenUid) + + // Sentinel deletion happens in the caller after this function returns (step 10) + void log({ + severity: 'INFO', + code: 'ERASURE_EXECUTED', + message: `erasure executed for ${citizenUid}`, + data: { citizenUid, requestId, reportCount: reportIds.length }, + }) +} + +export const erasureSweep = onSchedule( + { schedule: 'every 15 minutes', region: 'asia-southeast1' }, + async () => { + await erasureSweepCore({ db: getFirestore(), auth: getAuth(), storage: getStorage() }) + }, +) diff --git a/functions/src/triggers/retention-sweep.ts b/functions/src/triggers/retention-sweep.ts new file mode 100644 index 00000000..fa2dc300 --- /dev/null +++ b/functions/src/triggers/retention-sweep.ts @@ -0,0 +1,160 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler' +import { getFirestore, type Firestore } from 'firebase-admin/firestore' +import { getStorage, type Storage } from 'firebase-admin/storage' +import { logDimension } from '@bantayog/shared-validators' + +const log = logDimension('retentionSweep') + +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000 +const ONE_MONTH_MS = 30 * 24 * 60 * 60 * 1000 +const ACTIVE_STATUSES = ['pending_review', 'approved_pending_anonymization', 'executing'] + +export interface RetentionSweepInput { + db: Firestore + storage: Storage + now?: () => number +} + +export interface RetentionSweepResult { + anonymized: number + hardDeleted: number +} + +export async function retentionSweepCore( + input: RetentionSweepInput, +): Promise { + const now = input.now ?? (() => Date.now()) + const result: RetentionSweepResult = { anonymized: 0, hardDeleted: 0 } + + // Load UIDs with active erasure requests for in-memory exclusion + const activeErasureSnap = await input.db + .collection('erasure_requests') + .where('status', 'in', ACTIVE_STATUSES) + .get() + const activeErasureUids = new Set( + activeErasureSnap.docs.map((d) => d.data().citizenUid as string), + ) + + // --- 1-week threshold: anonymize unverified, unerasured reports --- + const anonymizeThreshold = now() - ONE_WEEK_MS + const toAnonymize = await input.db + .collection('reports') + .where('verified', '==', false) + .where('submittedAt', '<', anonymizeThreshold) + .get() + + for (const doc of toAnonymize.docs) { + const data = doc.data() + // Skip reports already erased or belonging to citizens with active erasure requests + if (data.submittedBy === 'citizen_deleted') continue + if (activeErasureUids.has(data.submittedBy as string)) continue + if (data.retentionAnonymizedAt) continue + + try { + // Read report_private for msisdnHash BEFORE nulling + const privateSnap = await input.db.collection('report_private').doc(doc.id).get() + const senderMsisdnHash = privateSnap.data()?.senderMsisdnHash as string | undefined + + // Null report_private PII + if (privateSnap.exists) { + await input.db.collection('report_private').doc(doc.id).update({ + citizenName: null, + rawPhone: null, + gpsExact: null, + addressText: null, + }) + } + + // Null report_contacts + const contactSnap = await input.db.collection('report_contacts').doc(doc.id).get() + if (contactSnap.exists) { + const nulled: Record = {} + for (const key of Object.keys(contactSnap.data() ?? {})) { + if (key !== 'reportId') nulled[key] = null + } + await input.db.collection('report_contacts').doc(doc.id).update(nulled) + } + + // 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() + } + + // Null SMS records by senderMsisdnHash + if (senderMsisdnHash) { + const sessSnap = await input.db + .collection('sms_sessions') + .where('senderMsisdnHash', '==', senderMsisdnHash) + .get() + for (const sess of sessSnap.docs) { + await sess.ref.update({ senderMsisdnHash: null, msisdn: null }) + } + + const inboxSnap = await input.db + .collection('sms_inbox') + .where('senderMsisdnHash', '==', senderMsisdnHash) + .get() + for (const msg of inboxSnap.docs) { + await msg.ref.update({ senderMsisdnHash: null, msisdn: null, rawBody: null }) + } + } + + await doc.ref.update({ + mediaRedacted: true, + retentionAnonymizedAt: now(), + retentionHardDeleteEligibleAt: now() + ONE_MONTH_MS, + }) + result.anonymized++ + } catch (err: unknown) { + log({ + severity: 'ERROR', + code: 'RETENTION_ANONYMIZE_FAILED', + message: `retention anonymize failed for ${doc.id}: ${String(err)}`, + data: { reportId: doc.id, error: String(err) }, + }) + } + } + + // --- 1-month threshold: hard-delete eligible reports --- + const toDelete = await input.db + .collection('reports') + .where('retentionHardDeleteEligibleAt', '<', now()) + .get() + + for (const doc of toDelete.docs) { + const data = doc.data() + // Skip reports belonging to citizens with active erasure requests + if (activeErasureUids.has(data.submittedBy as string)) continue + + 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) }, + }) + } + } + + return result +} + +export const retentionSweep = onSchedule( + { schedule: 'every 24 hours', region: 'asia-southeast1' }, + async () => { + await retentionSweepCore({ db: getFirestore(), storage: getStorage() }) + }, +) diff --git a/infra/firebase/firestore.rules b/infra/firebase/firestore.rules index 2a2eee5f..86b7e34c 100644 --- a/infra/firebase/firestore.rules +++ b/infra/firebase/firestore.rules @@ -496,8 +496,39 @@ service cloud.firestore { } match /erasure_requests/{id} { - allow read: if isSuperadmin() && isActivePrivileged(); - allow write: if false; + allow create: if isAuthed() + && request.auth.uid == request.resource.data.citizenUid + && request.resource.data.status == 'pending_review' + && request.resource.data.keys().hasOnly([ + 'citizenUid', 'status', 'legalHold', 'requestedAt' + ]) + && request.resource.data.keys().hasAll([ + 'citizenUid', 'status', 'requestedAt' + ]) + && request.resource.data.citizenUid is string + && request.resource.data.status is string + && request.resource.data.legalHold is bool + && request.resource.data.requestedAt is int; + allow read: if (isAuthed() && request.auth.uid == resource.data.citizenUid) + || (isSuperadmin() && isActivePrivileged()); + // All other writes (status transitions, legalHold) are service-account only. + allow update, delete: if false; + + match /auditLog/{eventId} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; + } + } + + match /erasure_active/{citizenUid} { + allow create: if isAuthed() && request.auth.uid == citizenUid + && request.resource.data.keys().hasOnly(['citizenUid', 'createdAt']) + && request.resource.data.keys().hasAll(['citizenUid', 'createdAt']) + && request.resource.data.citizenUid is string + && request.resource.data.createdAt is int; + allow read: if (isAuthed() && request.auth.uid == citizenUid) + || (isSuperadmin() && isActivePrivileged()); + allow update, delete: if false; } match /system_health/{id} { diff --git a/infra/firebase/firestore.rules.template b/infra/firebase/firestore.rules.template index 3916e58d..7c59a302 100644 --- a/infra/firebase/firestore.rules.template +++ b/infra/firebase/firestore.rules.template @@ -473,8 +473,39 @@ service cloud.firestore { } match /erasure_requests/{id} { - allow read: if isSuperadmin() && isActivePrivileged(); - allow write: if false; + allow create: if isAuthed() + && request.auth.uid == request.resource.data.citizenUid + && request.resource.data.status == 'pending_review' + && request.resource.data.keys().hasOnly([ + 'citizenUid', 'status', 'legalHold', 'requestedAt' + ]) + && request.resource.data.keys().hasAll([ + 'citizenUid', 'status', 'requestedAt' + ]) + && request.resource.data.citizenUid is string + && request.resource.data.status is string + && request.resource.data.legalHold is bool + && request.resource.data.requestedAt is int; + allow read: if (isAuthed() && request.auth.uid == resource.data.citizenUid) + || (isSuperadmin() && isActivePrivileged()); + // All other writes (status transitions, legalHold) are service-account only. + allow update, delete: if false; + + match /auditLog/{eventId} { + allow read: if isSuperadmin() && isActivePrivileged(); + allow write: if false; + } + } + + match /erasure_active/{citizenUid} { + allow create: if isAuthed() && request.auth.uid == citizenUid + && request.resource.data.keys().hasOnly(['citizenUid', 'createdAt']) + && request.resource.data.keys().hasAll(['citizenUid', 'createdAt']) + && request.resource.data.citizenUid is string + && request.resource.data.createdAt is int; + allow read: if (isAuthed() && request.auth.uid == citizenUid) + || (isSuperadmin() && isActivePrivileged()); + allow update, delete: if false; } match /system_health/{id} { diff --git a/infra/firebase/storage.rules b/infra/firebase/storage.rules index 7ac04f7c..52742031 100644 --- a/infra/firebase/storage.rules +++ b/infra/firebase/storage.rules @@ -15,12 +15,17 @@ service firebase.storage { } function isMuniAdmin() { return isAuthed() && role() == 'municipal_admin'; } function isSuperadmin() { return isAuthed() && role() == 'provincial_superadmin'; } + function isCitizen() { return isAuthed() && role() == 'citizen'; } // Report media: path is report_media/{municipalityId}/{reportId}/{filename} // Admin SDK uploads; admin-of-muni reads. match /report_media/{municipalityId}/{reportId}/{filename} { + // NOTE: Citizen read is unscoped because Storage rules cannot query + // Firestore for report ownership. Future fix: embed owner UID in object + // metadata and validate against request.auth.uid. allow read: if (isMuniAdmin() && myMunicipality() == municipalityId) - || (isSuperadmin() && municipalityId in permittedMunis()); + || (isSuperadmin() && municipalityId in permittedMunis()) + || isCitizen(); allow write: if false; }