Skip to content

Review fixes for #271: audit_events RLS/erasure/CSV-injection + tests - #1

Open
amal66 wants to merge 5 commits into
jmooves:upstream-pr/audit-historyfrom
Open-Legal-Products:review-fixes/pr271-audit-events
Open

Review fixes for #271: audit_events RLS/erasure/CSV-injection + tests#1
amal66 wants to merge 5 commits into
jmooves:upstream-pr/audit-historyfrom
Open-Legal-Products:review-fixes/pr271-audit-events

Conversation

@amal66

@amal66 amal66 commented Aug 1, 2026

Copy link
Copy Markdown

Implements the review feedback on Open-Legal-Products#271, packaged as five focused commits on top of your branch so it's easy to merge or cherry-pick.

  • ec34667 fix(db) — lock down audit_events like every other backend table: revoke anon/authenticated, enable RLS, and move the DDL above the service_role grant block so fresh installs don't lose backend write access.
  • 3d4e0d5 fix(privacy) — purge a user's audit rows on account deletion and include them in the /user/export payload (they carry emails, titles and prompt excerpts).
  • 484dadc fix(audit) — harden the /audit route: escape CSV formula injection, rate-limit and MFA-gate the export, and bound limit/offset/filter inputs.
  • 9747881 fix(audit) — mine doc_replicated copies into history and record project document uploads.
  • 3cba69a fix(history) — show an error state on the History page and cancel stale requests so out-of-order responses can't clobber newer ones.

Verified on this branch: tsc --noEmit clean for backend and frontend; backend vitest 273 passed (including the 14 new audit tests), frontend vitest 38 passed.

The branch is based directly on your audit-history branch — rebasing onto current main is intentionally left to you.

amal66 and others added 5 commits August 1, 2026 20:01
…order)

WHY THIS MATTERS
This repo's threat model explicitly includes direct PostgREST access with the
public anon key — which is why schema.sql revokes anon/authenticated on every
backend-owned table. audit_events shipped with neither a revoke nor RLS, so on a
hosted Supabase deployment its default privileges leave the whole table readable
AND writable from the browser: any visitor could dump every user's email, chat
titles and prompt excerpts, or forge/delete audit rows — which defeats the whole
point of an append-only audit trail.

WHAT IS RLS / PostgREST default access
Supabase exposes every table in schema `public` over PostgREST. Whether the
browser `anon`/`authenticated` roles can touch a table is governed by two
things: (1) SQL table GRANTs (managed Supabase's default ACLs grant these roles
broad privileges on new tables), and (2) Row-Level Security. With RLS disabled
and the default grants in place, the table is wide open. The repo's convention
is defense-in-depth: `revoke all ... from anon, authenticated` removes the
grant, and `enable row level security` (with no policies) means even if a grant
slips back the rows are invisible. service_role bypasses RLS, so the backend
path is unaffected.

HOW IT WORKS
- schema.sql: audit_events now has `revoke all ... from anon, authenticated` in
  the revoke block and `enable row level security`, matching every sibling
  table.
- Grant ordering (F4): `grant ... on all tables in schema public to
  service_role` only covers tables that already exist when it runs. The table
  was defined *after* that block, so a fresh plain-Postgres install created it
  with no service_role privileges and the backend's inserts failed
  permission-denied — silently, because recordAudit swallows errors. The DDL is
  moved above the grant block so the blanket grant covers it.
- migration 20260728: adds the same revoke + RLS, plus an explicit
  `grant select, insert, update, delete ... to service_role` so a fresh apply
  works even where service_role has no default ACL for new tables. The old
  comment ("no RLS policies needed, like other app tables") inverted the
  convention and is corrected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… in export

WHY THIS MATTERS
audit_events stores personal data: the user's id, email, chat/document titles
and prompt excerpts. Account deletion erased chats, projects, documents and
workflows but left the audit rows behind forever — a GDPR "right to erasure"
gap, and the rows also became orphans pointing at chats/projects that no longer
exist. Separately, GET /user/export (the user's own copy of their data) omitted
audit rows, so the export was incomplete.

WHAT IS "erasure completeness"
When a user deletes their account, every table keyed by their identity must be
swept — not just the primary feature tables. Any table carrying user_id (or
their email, titles, excerpts) is in scope. Audit trails are easy to overlook
precisely because they're written by a fire-and-forget side path, but they hold
some of the most sensitive text in the system (prompt excerpts).

HOW IT WORKS
- deleteUserAccountData: adds `audit_events.delete().eq("user_id", userId)` to
  the batched deletion set, alongside workflows/projects/etc. Keyed by user_id so
  it removes exactly the departing user's rows.
- buildUserAccountExport: adds an `audit_events` section (the user's own rows,
  ordered by created_at) so the export mirrors what deletion removes.
- Test: the account-deletion fixture gains audit rows for two users and asserts
  only the other user's row survives (a1/a2 purged, a-other kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…A, input bounds

WHY THIS MATTERS
The history route had four independent weaknesses: CSV export could smuggle a
formula into a victim's spreadsheet, /audit/export lacked the export limiter and
MFA gate that every other data export has, an out-of-range ?page= crashed with a
500, and a malformed from/to date crashed with a 500. Titles are attacker-
controllable across shared projects, so these are reachable by another user.

WHAT IS CSV FORMULA INJECTION
Excel/Google Sheets evaluate any cell whose text begins with =, +, -, @, a tab
or a carriage return as a *formula* when the file is opened. A chat titled
=HYPERLINK("http://evil","invoice") therefore executes on export in the
victim's spreadsheet — data exfiltration / phishing with no macro prompt. The
fix (OWASP's recommendation) prefixes a single quote to any value starting with
a trigger char, forcing the value to be treated as literal text. The quote-
trigger regex also gains \r so a leading carriage return is both escaped and
quoted.

HOW IT WORKS
- csvCell (F3): prefixes ' when the value matches /^[=+\-@\t\r]/, and the
  CSV-quote test now includes \r.
- Export limiter + MFA (F5): app.ts adds app.get("/audit/export",
  exportLimiter) (10/hr) and the route gains requireMfaIfEnrolled, matching
  /user/export. A 2000-row export that can include other users' emails no longer
  runs under only the general limiter and plain auth.
- Page clamp (F7): parseQuery clamps page into [1, 100000]. Previously
  ?page=99999999999999 produced a ~5e15 OFFSET that PostgREST rejected as a 500;
  the clamp keeps the offset inside Postgres' integer range.
- Date validation (F8): from/to must match ^\d{4}-\d{2}-\d{2}$ (they come from
  <input type="date">). parseQuery now returns a discriminated result and the
  handlers reply 400 on bad input instead of building "...ZT23:59:59.999Z" and
  500ing.

Tests pin csvCell escaping for every trigger char, page clamping/flooring, date
rejection/acceptance, and (with queryEvents/accessibleProjectIds exported)
visibility scoping: own-events OR accessible-project events for owned+shared
projects, own-only when none, and owned/shared id de-duplication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oads

WHY THIS MATTERS
The history feature under-recorded reality in two ways. Replicated documents
were attributed to the wrong file and lost their id, and project document
uploads were never recorded at all — so the audit trail silently disagreed with
what the user actually did.

WHAT IS EVENT MINING
recordChatTurn derives one audit row per artifact a chat turn produced by
walking the persisted assistant-event stream (doc_created, doc_edited,
doc_replicated, workflow_applied). Each event type stores its payload
differently, so the miner has to read each shape correctly.

HOW IT WORKS
- doc_replicated (F6): per chat/streaming.ts, this event's top-level `filename`
  is the SOURCE document and there is no top-level document_id — the produced
  copies live in a `copies: [{ new_filename, document_id, version_id }]` array,
  and one event can produce several. The miner previously read the top-level
  fields, so it logged one row titled with the source filename and a null id.
  It now iterates `copies`, emitting one document.generated row per copy with
  that copy's new_filename and document_id.
- Project uploads (F6): POST /projects/:id/documents calls the project-scoped
  handleDocumentUpload in projects.ts, a duplicate of the instrumented one in
  documents.ts, and it had no recordAudit call — so project uploads never
  appeared in history. It now records a document.uploaded event
  (surface: "project", the project id, the new document id) on success,
  fire-and-forget like the sibling handler.

Tests exercise the miner directly: a mixed turn (created/edited/workflow) maps
to the right actions, a doc_replicated with two copies yields exactly two
document.generated rows carrying the copies' filenames/ids (and never the source
filename), and an empty-copies event yields only the chat.message row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… History page

WHY THIS MATTERS
Two UX/correctness bugs. When the fetch failed the catch cleared the list, so a
backend outage rendered the "No history yet" empty state — telling the user they
have no history when the truth is the request failed. And rapid filter changes
raced: because responses can arrive out of order, a slow earlier request could
land after a faster later one and overwrite it with stale rows, and a "Load
more" issued mid-filter-change appended a page from the old filter (wrong rows
and duplicate React key={e.id} warnings).

WHAT IS AN OUT-OF-ORDER RESPONSE RACE
The browser does not guarantee that fetches resolve in the order they were
started. If you fire request A (filter=all) then request B (filter=chat), B may
resolve first and A second, leaving the UI showing "all" results under a "chat"
filter. The standard fix is an AbortController: each new load aborts the
previous in-flight request, and the handler ignores any response whose signal
was aborted so a superseded request can neither overwrite fresher state nor
surface a false error.

HOW IT WORKS
- getAuditHistory (mikeApi) gains an optional AbortSignal, threaded into the
  underlying fetch via RequestInit.
- HistoryTable keeps the live controller in a ref. Each load() aborts the
  previous controller, starts a new one, and passes its signal down. Responses
  are dropped when controller.signal.aborted; the aborted-request rejection
  (AbortError) is swallowed rather than treated as an error. The effect's
  cleanup aborts on unmount.
- A dedicated error state renders "Couldn't load your history — Try again"
  (with a retry button) instead of the misleading empty state; the genuine
  empty state only shows when there was no error.
- Nit (F12): drops a leaked private-fork "(Clue custom)" comment from
  HistorySkeuoIcon.

Frontend tsc is clean and the existing suite passes; the History page has no
pre-existing component test to extend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@amal66
amal66 force-pushed the review-fixes/pr271-audit-events branch from 6c01b3d to 3cba69a Compare August 2, 2026 03:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant