Skip to content

Commit 9de7755

Browse files
committed
feat: enforce export pii masking and add observability/control docs
1 parent cc4e90f commit 9de7755

7 files changed

Lines changed: 209 additions & 19 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ Before deploying, ensure quality gates pass:
129129
- **[AGENCY_ADMIN_GUIDE.md](docs/AGENCY_ADMIN_GUIDE.md)** - Guide for government staff
130130
- **[PUBLIC_USER_GUIDE.md](docs/PUBLIC_USER_GUIDE.md)** - Guide for citizens
131131
- **[OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md)** - Production operations guide
132+
- **[OBSERVABILITY.md](docs/OBSERVABILITY.md)** - Logging, metrics, and alerting baseline
132133
- **[SECURITY_AUDIT_GUIDE.md](docs/SECURITY_AUDIT_GUIDE.md)** - Security audit procedures
134+
- **[CONTROL_MAPPING.md](docs/CONTROL_MAPPING.md)** - Federal-ready control evidence mapping
133135
- **[ACCESSIBILITY_TRACKER.md](docs/ACCESSIBILITY_TRACKER.md)** - Accessibility compliance tracking
134136
- **[PERFORMANCE_NOTES.md](docs/PERFORMANCE_NOTES.md)** - Performance optimization guide
135137

docs/API_V1.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ Request body:
9393
"filters": {
9494
"comment_statuses": ["published"],
9595
"date_from": "2026-01-01T00:00:00Z",
96-
"date_to": "2026-01-31T23:59:59Z"
96+
"date_to": "2026-01-31T23:59:59Z",
97+
"include_pii": false
9798
}
9899
}
99100
```
@@ -102,6 +103,10 @@ Notes:
102103

103104
- `export_type`: `csv | zip | combined`
104105
- Creates job and triggers background generation function.
106+
- PII behavior:
107+
- `include_pii` defaults to `false`.
108+
- Email fields are masked unless `include_pii=true` and caller has authorized platform role.
109+
- When requesting unmasked PII, include `pii_reason` in filters.
105110

106111
### `GET /v1/exports/{id}`
107112

docs/CONTROL_MAPPING.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# OpenComments Federal-Ready Control Mapping (Baseline)
2+
3+
This baseline mapping provides implementation evidence references for common NIST/FISMA-style control families.
4+
It is an engineering readiness artifact, not a formal authorization package.
5+
6+
## AC - Access Control
7+
8+
- AC-2 / AC-3 (Account and authorization enforcement)
9+
- Evidence:
10+
- `supabase/migrations/20260211000100_reconcile_schema_phase1.sql`
11+
- `supabase/migrations/20260211000400_agency_rls_reconciliation.sql`
12+
- `src/hooks/usePermissions.ts`
13+
- `src/contexts/AgencyContext.tsx`
14+
15+
## AU - Audit and Accountability
16+
17+
- AU-2 / AU-12 (Audit event generation and retention)
18+
- Evidence:
19+
- `moderation_logs` table and usage in `supabase/functions/submit-comment/index.ts`
20+
- export lifecycle state transitions in `exports`
21+
22+
## SI - System and Information Integrity
23+
24+
- SI-10 / SI-11 (Input validation and error handling)
25+
- Evidence:
26+
- `src/lib/validation.ts`
27+
- `src/lib/validation.test.ts`
28+
- `supabase/functions/submit-comment/index.ts`
29+
30+
## SC - System and Communications Protection
31+
32+
- SC-7 / SC-5 (Boundary protection and throttling)
33+
- Evidence:
34+
- `supabase/migrations/20260211000300_api_rate_limiting.sql`
35+
- `supabase/functions/public-api/index.ts`
36+
- `supabase/functions/submit-comment/index.ts`
37+
38+
## MP/PL - Data Handling and Policy
39+
40+
- PII export minimization and masking defaults
41+
- Evidence:
42+
- `supabase/functions/generate-export/index.ts` (email masking by default; explicit gated inclusion)
43+
- `docs/API_V1.md`
44+
- `docs/DATA_DICTIONARY.md`
45+
46+
## IR/CP - Incident and Recovery Readiness
47+
48+
- Operational triage and recovery guidance
49+
- Evidence:
50+
- `docs/OPERATIONS_RUNBOOK.md`
51+
- `docs/OBSERVABILITY.md`
52+
53+
## Evidence Maintenance
54+
55+
- Update this mapping when:
56+
- control-relevant migrations are added
57+
- security-critical behavior changes
58+
- operational controls or runbooks are revised

docs/OBSERVABILITY.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# OpenComments Observability Baseline
2+
3+
This document defines baseline operational observability for the managed-cloud deployment model (Supabase + Netlify).
4+
5+
## Objectives
6+
7+
- Detect platform-impacting failures quickly.
8+
- Preserve enough context for incident triage and audit.
9+
- Track key user and agency workflow reliability.
10+
11+
## Logging Baseline
12+
13+
## Application and Edge Functions
14+
15+
- Every API response should include `X-Request-Id`.
16+
- Edge function errors must log:
17+
- request identifier (or generated correlation ID)
18+
- endpoint/function name
19+
- principal type (`anon`, `authenticated`, `service`)
20+
- stable error code and message
21+
- Avoid logging raw secrets and full PII values.
22+
23+
## Database and Job Flows
24+
25+
- Export lifecycle transitions are persisted in `exports` (`pending -> processing -> completed|failed`).
26+
- Moderation and submission events are persisted in `moderation_logs`.
27+
- Rate-limit decisions are persisted in `api_rate_limits` (windowed counters).
28+
29+
## Operational Signals
30+
31+
- Public API health:
32+
- success rate (`2xx/3xx`)
33+
- error rate (`4xx/5xx`) with `429` tracked separately
34+
- latency p50/p95/p99
35+
- Submission pipeline:
36+
- comment submit success/failure counts
37+
- CAPTCHA verification failure rate
38+
- attachment upload warning/failure rate
39+
- Export pipeline:
40+
- queue depth (`pending` count)
41+
- export completion time
42+
- export failure rate by error class
43+
44+
## Alerting Starter Thresholds
45+
46+
- API error rate > 5% over 10 minutes.
47+
- Export failure rate > 10% over 30 minutes.
48+
- No successful comment submissions for 30 minutes during expected traffic windows.
49+
- Sudden `429` spikes (possible abuse or misconfigured client integrations).
50+
51+
## Runbook Links
52+
53+
- Operational handling: `docs/OPERATIONS_RUNBOOK.md`
54+
- Security audit process: `docs/SECURITY_AUDIT_GUIDE.md`
55+
- API contract: `docs/API_V1.md`

docs/plan/M06-security-and-compliance-hardening.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ Implement engineering controls for a federal-ready baseline posture.
1616
## Implementation checklist
1717

1818
- [ ] Add append-only audit logging for key actions.
19-
- [ ] Define PII data handling and export masking rules.
19+
- [x] Define PII data handling and export masking rules.
2020
- [ ] Add abuse detection/throttling controls.
21-
- [ ] Produce baseline control mapping document.
21+
- [x] Produce baseline control mapping document.
2222

2323
## Acceptance criteria
2424

2525
- [ ] Sensitive operations are traceable and auditable.
26-
- [ ] PII handling behavior is documented and enforced.
26+
- [x] PII handling behavior is documented and enforced.
2727

2828
## Risks/blockers
2929

@@ -36,3 +36,5 @@ Implement engineering controls for a federal-ready baseline posture.
3636
## Progress log (append-only)
3737

3838
- 2026-02-11: Scope defined.
39+
- 2026-02-11: Hardened export behavior in `supabase/functions/generate-export/index.ts` to mask commenter email by default and only include raw PII when explicitly requested with authorized platform role.
40+
- 2026-02-11: Added `docs/CONTROL_MAPPING.md` as baseline NIST/FISMA-style evidence mapping artifact.

docs/plan/M08-quality-ci-cd-and-observability.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Restore engineering quality gates and add operational observability.
1919
- [x] Reduce/resolve blocking TypeScript errors.
2020
- [x] Add initial unit/integration tests.
2121
- [x] Add CI pipeline for lint, typecheck, build, tests.
22-
- [ ] Add basic app and job observability docs.
22+
- [x] Add basic app and job observability docs.
2323

2424
## Acceptance criteria
2525

@@ -42,3 +42,4 @@ Restore engineering quality gates and add operational observability.
4242
- 2026-02-11: Added CI workflow `.github/workflows/ci.yml` with required `lint`, `typecheck`, `test:ci`, and `build` jobs on pull requests and pushes to `main`.
4343
- 2026-02-11: Added `package.json` scripts `typecheck` and `test:ci` to standardize local and CI quality commands.
4444
- 2026-02-11: Added first unit tests in `src/lib/validation.test.ts` and confirmed `npm run test:ci` executes real tests (6 passing).
45+
- 2026-02-11: Added `docs/OBSERVABILITY.md` baseline for logging, metrics, alerting thresholds, and runbook linkage.

supabase/functions/generate-export/index.ts

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ interface ExportComment {
2626
comment_attachments?: any[]
2727
}
2828

29+
interface ExportOptions {
30+
includePii: boolean
31+
}
32+
2933
serve(async (req) => {
3034
if (req.method === 'OPTIONS') {
3135
return new Response('ok', { headers: corsHeaders })
@@ -62,25 +66,27 @@ serve(async (req) => {
6266
p_progress_percent: 5
6367
})
6468

65-
const { agency_id, docket_id, export_type, filters_json } = exportJob
69+
const { agency_id, docket_id, export_type, filters_json, created_by } = exportJob
6670
const filters = filters_json || {}
71+
const includePii = await resolvePiiExportPermission(supabase, created_by, filters)
72+
const exportOptions: ExportOptions = { includePii }
6773

6874
let fileContent: Uint8Array
6975
let fileName: string
7076
let contentType: string
7177

7278
if (export_type === 'csv') {
73-
const result = await generateCSVExport(supabase, agency_id, docket_id, filters)
79+
const result = await generateCSVExport(supabase, agency_id, docket_id, filters, exportOptions)
7480
fileContent = new TextEncoder().encode(result.csv)
7581
fileName = `comments_export_${new Date().toISOString().split('T')[0]}.csv`
7682
contentType = 'text/csv'
7783
} else if (export_type === 'zip') {
78-
const result = await generateZIPExport(supabase, agency_id, docket_id, filters)
84+
const result = await generateZIPExport(supabase, agency_id, docket_id, filters, exportOptions)
7985
fileContent = result.zipBuffer
8086
fileName = `attachments_export_${new Date().toISOString().split('T')[0]}.zip`
8187
contentType = 'application/zip'
8288
} else if (export_type === 'combined') {
83-
const result = await generateCombinedExport(supabase, agency_id, docket_id, filters)
89+
const result = await generateCombinedExport(supabase, agency_id, docket_id, filters, exportOptions)
8490
fileContent = result.zipBuffer
8591
fileName = `combined_export_${new Date().toISOString().split('T')[0]}.zip`
8692
contentType = 'application/zip'
@@ -173,6 +179,47 @@ function csvCell(value: unknown): string {
173179
return `"${str.replace(/"/g, '""')}"`
174180
}
175181

182+
function maskEmail(email?: string): string {
183+
if (!email) return ''
184+
const trimmed = email.trim()
185+
const atIndex = trimmed.indexOf('@')
186+
if (atIndex <= 1) {
187+
return '***'
188+
}
189+
return `${trimmed[0]}***${trimmed.slice(atIndex)}`
190+
}
191+
192+
async function resolvePiiExportPermission(
193+
supabase: any,
194+
createdBy: string | null | undefined,
195+
filters: any
196+
): Promise<boolean> {
197+
if (filters?.include_pii !== true) {
198+
return false
199+
}
200+
201+
if (!filters?.pii_reason || typeof filters.pii_reason !== 'string') {
202+
return false
203+
}
204+
205+
if (!createdBy) {
206+
return false
207+
}
208+
209+
const { data, error } = await supabase
210+
.from('platform_roles')
211+
.select('role')
212+
.eq('user_id', createdBy)
213+
.in('role', ['super_owner', 'super_user'])
214+
.maybeSingle()
215+
216+
if (error) {
217+
return false
218+
}
219+
220+
return !!data
221+
}
222+
176223
async function fetchExportComments(
177224
supabase: any,
178225
agencyId: string,
@@ -234,14 +281,14 @@ async function fetchExportComments(
234281
return (data || []) as ExportComment[]
235282
}
236283

237-
function buildCSV(comments: ExportComment[]): string {
284+
function buildCSV(comments: ExportComment[], options: ExportOptions): string {
238285
const headers = [
239286
'Comment ID',
240287
'Docket ID',
241288
'Docket Title',
242289
'Reference Code',
243290
'Commenter Name',
244-
'Commenter Email',
291+
options.includePii ? 'Commenter Email' : 'Commenter Email (Masked)',
245292
'Organization',
246293
'Comment Text',
247294
'Status',
@@ -261,7 +308,7 @@ function buildCSV(comments: ExportComment[]): string {
261308
csvCell(docket?.title || ''),
262309
csvCell(docket?.reference_code || ''),
263310
csvCell(comment.commenter_name || ''),
264-
csvCell(comment.commenter_email || ''),
311+
csvCell(options.includePii ? (comment.commenter_email || '') : maskEmail(comment.commenter_email)),
265312
csvCell(comment.commenter_organization || ''),
266313
csvCell(comment.content || ''),
267314
csvCell(comment.status || ''),
@@ -310,13 +357,25 @@ async function addAttachmentsToZip(zip: JSZip, supabase: any, comments: ExportCo
310357
return attachmentCount
311358
}
312359

313-
async function generateCSVExport(supabase: any, agencyId: string, docketId: string | null, filters: any) {
360+
async function generateCSVExport(
361+
supabase: any,
362+
agencyId: string,
363+
docketId: string | null,
364+
filters: any,
365+
options: ExportOptions
366+
) {
314367
const comments = await fetchExportComments(supabase, agencyId, docketId, filters)
315-
const csv = buildCSV(comments)
368+
const csv = buildCSV(comments, options)
316369
return { csv, count: comments.length }
317370
}
318371

319-
async function generateZIPExport(supabase: any, agencyId: string, docketId: string | null, filters: any) {
372+
async function generateZIPExport(
373+
supabase: any,
374+
agencyId: string,
375+
docketId: string | null,
376+
filters: any,
377+
options: ExportOptions
378+
) {
320379
const comments = await fetchExportComments(supabase, agencyId, docketId, filters)
321380
const zip = new JSZip()
322381

@@ -329,7 +388,8 @@ async function generateZIPExport(supabase: any, agencyId: string, docketId: stri
329388
zip.file('manifest.json', JSON.stringify({
330389
generated_at: new Date().toISOString(),
331390
comment_count: comments.length,
332-
attachment_count: attachmentCount
391+
attachment_count: attachmentCount,
392+
pii_included: options.includePii
333393
}, null, 2))
334394

335395
const zipBuffer = await zip.generateAsync({
@@ -344,11 +404,17 @@ async function generateZIPExport(supabase: any, agencyId: string, docketId: stri
344404
}
345405
}
346406

347-
async function generateCombinedExport(supabase: any, agencyId: string, docketId: string | null, filters: any) {
407+
async function generateCombinedExport(
408+
supabase: any,
409+
agencyId: string,
410+
docketId: string | null,
411+
filters: any,
412+
options: ExportOptions
413+
) {
348414
const comments = await fetchExportComments(supabase, agencyId, docketId, filters)
349415
const zip = new JSZip()
350416

351-
zip.file('comments.csv', buildCSV(comments))
417+
zip.file('comments.csv', buildCSV(comments, options))
352418

353419
const includeAttachments = filters.include_attachments !== false
354420
let attachmentCount = 0
@@ -362,7 +428,8 @@ async function generateCombinedExport(supabase: any, agencyId: string, docketId:
362428
comment_count: comments.length,
363429
attachment_count: attachmentCount,
364430
includes_comments_csv: true,
365-
includes_attachments: includeAttachments
431+
includes_attachments: includeAttachments,
432+
pii_included: options.includePii
366433
}, null, 2))
367434

368435
const zipBuffer = await zip.generateAsync({

0 commit comments

Comments
 (0)