|
| 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