Skip to content

Commit d237f1e

Browse files
committed
feat: add immutable audit events and abuse telemetry controls
1 parent 9de7755 commit d237f1e

6 files changed

Lines changed: 278 additions & 7 deletions

File tree

docs/DATA_DICTIONARY.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,35 @@ Key fields:
165165
- `notes`
166166
- `created_at`
167167

168+
## Audit Events
169+
170+
Primary table: `audit_events`
171+
172+
Key fields:
173+
174+
- `table_name`
175+
- `record_id`
176+
- `agency_id`
177+
- `action` (`INSERT | UPDATE | DELETE`)
178+
- `actor_id`
179+
- `old_data` (`jsonb`)
180+
- `new_data` (`jsonb`)
181+
- `occurred_at`
182+
183+
## Abuse Events
184+
185+
Primary table: `abuse_events`
186+
187+
Key fields:
188+
189+
- `agency_id`
190+
- `docket_id`
191+
- `user_id`
192+
- `event_type`
193+
- `ip_address`
194+
- `details` (`jsonb`)
195+
- `created_at`
196+
168197
## Saved Dockets
169198

170199
Primary table: `saved_dockets`

docs/plan/M01-schema-and-contract-reconciliation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ Align database schema/RPC contracts with application code expectations.
4949
- 2026-02-11: Added follow-up migration `20260211000200_public_submission_hardening.sql` for `dockets.identity_mode` policy constraints and submission rate-limit indexes.
5050
- 2026-02-11: Added follow-up migration `20260211000300_api_rate_limiting.sql` for reusable API route throttling primitives (`api_rate_limits`, `check_api_rate_limit`).
5151
- 2026-02-11: Added follow-up migration `20260211000400_agency_rls_reconciliation.sql` to align dockets/comments/legacy attachments RLS with agency membership and platform role model.
52+
- 2026-02-11: Added follow-up migration `20260211000500_security_audit_and_abuse_events.sql` to introduce immutable audit trails and abuse-event telemetry primitives.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ Implement engineering controls for a federal-ready baseline posture.
1515

1616
## Implementation checklist
1717

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

2323
## Acceptance criteria
2424

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

2828
## Risks/blockers
@@ -38,3 +38,5 @@ Implement engineering controls for a federal-ready baseline posture.
3838
- 2026-02-11: Scope defined.
3939
- 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.
4040
- 2026-02-11: Added `docs/CONTROL_MAPPING.md` as baseline NIST/FISMA-style evidence mapping artifact.
41+
- 2026-02-11: Added migration `20260211000500_security_audit_and_abuse_events.sql` for append-only `audit_events` trigger capture across key tables and `abuse_events` telemetry storage.
42+
- 2026-02-11: Updated `supabase/functions/submit-comment/index.ts` to record abuse events for CAPTCHA failures/missing tokens, identity-gating failures, and rate-limit thresholds.

docs/plan/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ This folder tracks implementation progress for the OpenComments production roadm
3535
- `M01`: In progress
3636
- `M02`: In progress
3737
- `M03`: In progress
38-
- `M04`: Planned
39-
- `M05`: In progress
40-
- `M06`: Planned
38+
- `M04`: In progress
39+
- `M05`: Completed
40+
- `M06`: Completed
4141
- `M07`: Planned
42-
- `M08`: In progress
42+
- `M08`: Completed
4343
- `M09`: Planned

supabase/functions/submit-comment/index.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,38 @@ async function verifyHCaptcha(token: string, ipAddress: string | null): Promise<
107107
}
108108
}
109109

110+
async function logAbuseEvent(
111+
serviceClient: ReturnType<typeof createClient>,
112+
{
113+
agencyId,
114+
docketId,
115+
userId,
116+
ipAddress,
117+
eventType,
118+
details
119+
}: {
120+
agencyId?: string | null
121+
docketId?: string | null
122+
userId?: string | null
123+
ipAddress?: string | null
124+
eventType: string
125+
details?: Record<string, unknown>
126+
}
127+
) {
128+
try {
129+
await serviceClient.from('abuse_events').insert({
130+
agency_id: agencyId || null,
131+
docket_id: docketId || null,
132+
user_id: userId || null,
133+
event_type: eventType,
134+
ip_address: ipAddress || null,
135+
details: details || {}
136+
})
137+
} catch {
138+
// Intentionally swallow logging failures so submission flow can return primary errors.
139+
}
140+
}
141+
110142
serve(async (req) => {
111143
if (req.method === 'OPTIONS') {
112144
return new Response('ok', { headers: corsHeaders })
@@ -166,6 +198,7 @@ serve(async (req) => {
166198
.from('dockets')
167199
.select(`
168200
id,
201+
agency_id,
169202
slug,
170203
title,
171204
status,
@@ -200,6 +233,13 @@ serve(async (req) => {
200233

201234
const identityMode: IdentityMode = (docket.identity_mode as IdentityMode) || 'optional'
202235
if (identityMode === 'authenticated' && !user) {
236+
await logAbuseEvent(serviceClient, {
237+
agencyId: docket.agency_id,
238+
docketId: docket.id,
239+
ipAddress,
240+
eventType: 'identity_auth_required',
241+
details: { identity_mode: identityMode }
242+
})
203243
return jsonResponse({ error: 'Authentication is required for this docket' }, 401)
204244
}
205245
if ((identityMode === 'name_required' || identityMode === 'name_email_required') && !commenterName) {
@@ -219,10 +259,25 @@ serve(async (req) => {
219259

220260
if (docket.require_captcha) {
221261
if (!captchaToken) {
262+
await logAbuseEvent(serviceClient, {
263+
agencyId: docket.agency_id,
264+
docketId: docket.id,
265+
userId: user?.id || null,
266+
ipAddress,
267+
eventType: 'captcha_missing'
268+
})
222269
return jsonResponse({ error: 'CAPTCHA token is required' }, 400)
223270
}
224271
const captchaResult = await verifyHCaptcha(captchaToken, ipAddress)
225272
if (!captchaResult.ok) {
273+
await logAbuseEvent(serviceClient, {
274+
agencyId: docket.agency_id,
275+
docketId: docket.id,
276+
userId: user?.id || null,
277+
ipAddress,
278+
eventType: 'captcha_failed',
279+
details: { error_codes: captchaResult.errors || [] }
280+
})
226281
return jsonResponse({ error: 'CAPTCHA verification failed', details: captchaResult.errors || [] }, 400)
227282
}
228283
}
@@ -241,6 +296,14 @@ serve(async (req) => {
241296
.gte('created_at', oneHourAgo)
242297

243298
if (!ipRateError && (ipCount ?? 0) >= perHourLimit) {
299+
await logAbuseEvent(serviceClient, {
300+
agencyId: docket.agency_id,
301+
docketId: docket.id,
302+
userId: user?.id || null,
303+
ipAddress,
304+
eventType: 'ip_rate_limited',
305+
details: { limit_per_hour: perHourLimit }
306+
})
244307
return jsonResponse({ error: 'Rate limit exceeded for this IP. Please try again later.' }, 429)
245308
}
246309
}
@@ -254,6 +317,14 @@ serve(async (req) => {
254317
.is('deleted_at', null)
255318

256319
if (!userRateError && (userCount ?? 0) >= maxCommentsPerUser) {
320+
await logAbuseEvent(serviceClient, {
321+
agencyId: docket.agency_id,
322+
docketId: docket.id,
323+
userId: user.id,
324+
ipAddress,
325+
eventType: 'user_submission_limit',
326+
details: { max_comments_per_user: maxCommentsPerUser }
327+
})
257328
return jsonResponse({ error: 'You have reached the maximum submissions for this docket' }, 429)
258329
}
259330
} else if (commenterEmail) {
@@ -265,6 +336,13 @@ serve(async (req) => {
265336
.is('deleted_at', null)
266337

267338
if (!emailRateError && (emailCount ?? 0) >= maxCommentsPerUser) {
339+
await logAbuseEvent(serviceClient, {
340+
agencyId: docket.agency_id,
341+
docketId: docket.id,
342+
ipAddress,
343+
eventType: 'email_submission_limit',
344+
details: { commenter_email: commenterEmail, max_comments_per_user: maxCommentsPerUser }
345+
})
268346
return jsonResponse({ error: 'Maximum submissions reached for this email on this docket' }, 429)
269347
}
270348
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
-- 2026-02-11 00:50 Security hardening: append-only audit events + abuse event log
2+
3+
CREATE TABLE IF NOT EXISTS audit_events (
4+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
5+
table_name text NOT NULL,
6+
record_id text NOT NULL,
7+
agency_id uuid REFERENCES agencies(id) ON DELETE SET NULL,
8+
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
9+
actor_id uuid,
10+
old_data jsonb,
11+
new_data jsonb,
12+
occurred_at timestamptz NOT NULL DEFAULT now()
13+
);
14+
15+
CREATE INDEX IF NOT EXISTS idx_audit_events_agency_time
16+
ON audit_events (agency_id, occurred_at DESC);
17+
18+
CREATE INDEX IF NOT EXISTS idx_audit_events_table_record
19+
ON audit_events (table_name, record_id);
20+
21+
CREATE TABLE IF NOT EXISTS abuse_events (
22+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
23+
agency_id uuid REFERENCES agencies(id) ON DELETE SET NULL,
24+
docket_id uuid REFERENCES dockets(id) ON DELETE SET NULL,
25+
user_id uuid REFERENCES profiles(id) ON DELETE SET NULL,
26+
event_type text NOT NULL,
27+
ip_address inet,
28+
details jsonb NOT NULL DEFAULT '{}'::jsonb,
29+
created_at timestamptz NOT NULL DEFAULT now()
30+
);
31+
32+
CREATE INDEX IF NOT EXISTS idx_abuse_events_agency_time
33+
ON abuse_events (agency_id, created_at DESC);
34+
35+
CREATE INDEX IF NOT EXISTS idx_abuse_events_event_time
36+
ON abuse_events (event_type, created_at DESC);
37+
38+
CREATE OR REPLACE FUNCTION public.capture_audit_event()
39+
RETURNS trigger
40+
LANGUAGE plpgsql
41+
SECURITY DEFINER
42+
SET search_path = public
43+
AS $$
44+
DECLARE
45+
v_new_row jsonb;
46+
v_old_row jsonb;
47+
v_record_id text;
48+
v_agency_id uuid;
49+
v_docket_id uuid;
50+
BEGIN
51+
v_new_row := CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) ELSE NULL END;
52+
v_old_row := CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) ELSE NULL END;
53+
54+
v_record_id := COALESCE(
55+
(v_new_row->>'id'),
56+
(v_old_row->>'id'),
57+
(v_new_row->>'user_id'),
58+
(v_old_row->>'user_id'),
59+
'unknown'
60+
);
61+
62+
v_agency_id := NULL;
63+
IF TG_TABLE_NAME IN ('dockets', 'agency_members', 'agency_settings', 'exports', 'abuse_events') THEN
64+
v_agency_id := COALESCE((v_new_row->>'agency_id')::uuid, (v_old_row->>'agency_id')::uuid);
65+
ELSIF TG_TABLE_NAME = 'comments' THEN
66+
v_docket_id := COALESCE((v_new_row->>'docket_id')::uuid, (v_old_row->>'docket_id')::uuid);
67+
IF v_docket_id IS NOT NULL THEN
68+
SELECT d.agency_id INTO v_agency_id
69+
FROM dockets d
70+
WHERE d.id = v_docket_id;
71+
END IF;
72+
ELSIF TG_TABLE_NAME = 'moderation_logs' THEN
73+
SELECT d.agency_id INTO v_agency_id
74+
FROM comments c
75+
JOIN dockets d ON d.id = c.docket_id
76+
WHERE c.id = COALESCE((v_new_row->>'comment_id')::uuid, (v_old_row->>'comment_id')::uuid);
77+
END IF;
78+
79+
INSERT INTO audit_events (
80+
table_name,
81+
record_id,
82+
agency_id,
83+
action,
84+
actor_id,
85+
old_data,
86+
new_data,
87+
occurred_at
88+
)
89+
VALUES (
90+
TG_TABLE_NAME,
91+
v_record_id,
92+
v_agency_id,
93+
TG_OP,
94+
auth.uid(),
95+
v_old_row,
96+
v_new_row,
97+
now()
98+
);
99+
100+
IF TG_OP = 'DELETE' THEN
101+
RETURN OLD;
102+
END IF;
103+
RETURN NEW;
104+
END;
105+
$$;
106+
107+
DROP TRIGGER IF EXISTS trg_audit_dockets ON dockets;
108+
CREATE TRIGGER trg_audit_dockets
109+
AFTER INSERT OR UPDATE OR DELETE ON dockets
110+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
111+
112+
DROP TRIGGER IF EXISTS trg_audit_comments ON comments;
113+
CREATE TRIGGER trg_audit_comments
114+
AFTER INSERT OR UPDATE OR DELETE ON comments
115+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
116+
117+
DROP TRIGGER IF EXISTS trg_audit_agency_members ON agency_members;
118+
CREATE TRIGGER trg_audit_agency_members
119+
AFTER INSERT OR UPDATE OR DELETE ON agency_members
120+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
121+
122+
DROP TRIGGER IF EXISTS trg_audit_agency_settings ON agency_settings;
123+
CREATE TRIGGER trg_audit_agency_settings
124+
AFTER INSERT OR UPDATE OR DELETE ON agency_settings
125+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
126+
127+
DROP TRIGGER IF EXISTS trg_audit_exports ON exports;
128+
CREATE TRIGGER trg_audit_exports
129+
AFTER INSERT OR UPDATE OR DELETE ON exports
130+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
131+
132+
DROP TRIGGER IF EXISTS trg_audit_moderation_logs ON moderation_logs;
133+
CREATE TRIGGER trg_audit_moderation_logs
134+
AFTER INSERT OR UPDATE OR DELETE ON moderation_logs
135+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
136+
137+
DROP TRIGGER IF EXISTS trg_audit_abuse_events ON abuse_events;
138+
CREATE TRIGGER trg_audit_abuse_events
139+
AFTER INSERT OR UPDATE OR DELETE ON abuse_events
140+
FOR EACH ROW EXECUTE FUNCTION capture_audit_event();
141+
142+
ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY;
143+
ALTER TABLE abuse_events ENABLE ROW LEVEL SECURITY;
144+
145+
DROP POLICY IF EXISTS "Audit events readable by agency and platform" ON audit_events;
146+
CREATE POLICY "Audit events readable by agency and platform"
147+
ON audit_events FOR SELECT
148+
TO authenticated
149+
USING (
150+
is_platform_admin()
151+
OR (agency_id IS NOT NULL AND is_agency_member(agency_id))
152+
);
153+
154+
DROP POLICY IF EXISTS "Abuse events readable by agency and platform" ON abuse_events;
155+
CREATE POLICY "Abuse events readable by agency and platform"
156+
ON abuse_events FOR SELECT
157+
TO authenticated
158+
USING (
159+
is_platform_admin()
160+
OR (agency_id IS NOT NULL AND is_agency_member(agency_id))
161+
);

0 commit comments

Comments
 (0)