Skip to content

Application Audit Logging

Statement

This page covers application-layer audit logging — the in-app record of user actions, claim modifications, and admin operations. It is distinct from cloud-control-plane audit logs (gcloud / IAM / firewall changes), which are covered in Audit logging (cloud) and have 400-day immutable retention.

The application's audit posture is partial:

  • Per-claim review notes persist who said what about a claim, when, and tie a free-text note to an author.
  • A per-claim evidence-processing timeline ("audit trail" endpoint) reconstructs upload → queue → analysis → verdict times per piece of evidence.
  • A webhook_audit_log table captures every change to an org's webhook configuration with actor, previous config, new config, and timestamp. It has an org-scoped read endpoint and is the working prototype of the planned generalised audit-log pattern — but it covers exactly one surface area today.
  • There is no general-purpose audit-log table for the rest of the security-relevant user events: login success/failure, logout, password changes, role changes, user creation/deactivation, API-key creation/deletion, non-webhook org-config changes. These are visible at the application logging level (stdout / pm2) but are not captured as structured, queryable, retention-policied events.

Closing that gap — generalising the existing webhook_audit_log pattern into an audit_log table with append-only inserts at every security-relevant code path — is the largest follow-up on this page.

Implementation

What exists today

review_notes — claim review history

Schema (scripts/db/schema.sql line 168):

  • Per-claim free-text notes with author (author_name), text body (note), and created_at.
  • Indexed on (claim_ref) and (created_at DESC) for the per-claim history view.
  • Written by server/src/routes/notes.js and server/src/routes/actions.js.

This is sufficient for the analyst-facing "what did the team say about this claim?" question. It is not a security-event audit log — it does not record logins, role grants, or other out-of-claim-scope events.

GET /api/analysis/claim/:claimId/audit-trail

Defined in server/src/routes/analysis.js lines 112-150. Despite the endpoint name, this is not a security audit trail. It is a per-evidence processing timeline that joins evidence_items, layover_results, and analysis_runs to surface upload time, queue time, completion time, processing duration, and verdict. It is the "how did this evidence move through the pipeline" view.

This page is honest about that name overlap so an auditor reading the codebase doesn't conclude we already have a structured audit log when we don't.

webhook_audit_log — webhook configuration history

Schema (scripts/db/migrations/008_threshold_rules_audit.sql plus the org_ref column added in scripts/db/migrations/015_webhook_config_org_scope.sql):

  • Columns: id, user_id (FK to users with ON DELETE SET NULL so the audit row survives a user delete), user_email, action (text, default 'config_update'), previous_config (jsonb), new_config (jsonb), org_ref (FK to organizations), changed_at.
  • Written from server/src/routes/webhooks.js after every successful PUT to /api/webhooks/config — both the previous and the new full config snapshot are stored, so a row stands on its own as a complete diff.
  • Read via GET /api/webhooks/audit-log in the same file. Org-scoped by default; super-admins see all orgs or can pass ?org_ref= to narrow.
  • Retention: rows persist indefinitely until manually deleted. No cleanup job, no retention policy.
  • Not append-only enforced at the DB layer. Insert sites are the only writers in the current code, but Postgres would accept an UPDATE or DELETE from any code path that took the row's primary key. The proposed trigger in the planned-implementation section below would close that.

This is the existence proof that the generalised pattern works. The roadmap below is "do the same for the rest of the security-relevant surface area," not "design the table from scratch."

Application logs (stdout / pm2)

The Node application emits unstructured logs via console.log / console.error to pm2's log files. These contain operational signals (errors, request paths, occasional warnings). They are:

  • Captured by pm2 on the VM.
  • Not aggregated to Cloud Logging today (the VM has the logging.logWriter role on its service account, but the application does not use the Cloud Logging client library).
  • Not retention-policied at the application layer beyond pm2's local rotation defaults.

This is enough for present-day debugging and is not presented as an audit control.

What does not exist today

The following events have no structured, queryable, retention-policied audit record at the application layer. They are visible at most in pm2 logs for a short window:

  • Login success / failure / lockout.
  • Logout.
  • Password set / change.
  • User create / update / disable / re-enable / role change.
  • API-key issuance / rotation / deletion.
  • Org-level config changes other than webhook config — organization profile edits, threshold-rule edits outside the webhook surface, integration changes that don't go through /api/webhooks/config.
  • Admin reads of sensitive endpoints (e.g., GET /api/users/:id which returns password_plain today — see Authentication for that gap).
  • Cross-org reads by super-admins.

The cloud audit log records the infrastructure equivalents (IAM mutations, secret access, VM lifecycle) but does not see application-level user actions.

What this gap means in practice

For SOC 2 Type I, a written record of "we know what's missing and we have a plan" is more valuable than a hastily-built half-implementation. This page is that record. Until the gap is closed, "who did what" for application-level actions can be reconstructed only partially from:

  • review_notes (claim-scoped, free-text).
  • The cloud audit log (infrastructure-scoped, not user-action-scoped).
  • pm2 logs (best-effort, short retention, unstructured).

That is not a credible audit story for SOC 2 Type II or for any customer that asks "show me every action user X took last quarter."

Planned implementation

The minimal next step (named here so future-us has a target):

  1. audit_log table in Postgres — a generalisation of the existing webhook_audit_log. Columns: id (uuid), event_type (text), actor_user_id (uuid, ON DELETE SET NULL so the row survives the user being deleted), actor_role (text), actor_org_ref (uuid), target_type (text), target_id (uuid), event_data (jsonb), ip_address (inet), user_agent (text), created_at (timestamptz).
  2. Append-only insert at each security-relevant code path: routes/auth.js (login OK / fail / logout), routes/users.js (create / update / role change / disable / password change), routes/apiKeys.js (issue / delete / scope change), routes/organizations.js (settings change). The webhook insert site in routes/webhooks.js either migrates to the unified table or double-writes for a transition period — the call is taken at implementation time.
  3. Read endpoint GET /api/audit-log with org-scoping that mirrors the existing GET /api/webhooks/audit-log pattern — non-super-admins are pinned to their own org, super-admins see all orgs or pass ?org_ref= to narrow.
  4. Retention policy documented on this page once the table exists. Initial proposal: 400 days, matching the cloud audit log, to give a single retention number across both layers. The existing webhook_audit_log adopts the same retention.
  5. Append-only enforcement at the DB layer — a Postgres trigger that rejects UPDATE and DELETE on audit_log (and, retroactively, on webhook_audit_log). Mutation becomes a code change, not a runtime operation.

Status

partial — verified 2026-04-30.

What's in place:

  • Per-claim review notes with author and timestamp.
  • Per-claim evidence-processing timeline ("audit-trail" endpoint).
  • webhook_audit_log table with org-scoped read endpoint — one-surface working prototype of the planned generalised pattern.
  • pm2-captured application logs (best-effort, short retention).
  • Cloud-side audit logs for infrastructure actions (covered in Audit logging (cloud)).

Known gaps

  • No general-purpose audit_log table for the rest of the security-relevant surface area (auth events, user/role mutations, API-key lifecycle, non-webhook config changes). Largest gap.
  • No DB-level append-only enforcement anywhere in the application audit story — including on webhook_audit_log. Insert sites are the only writers in the current code, but Postgres would accept an UPDATE or DELETE.
  • No application-level retention policy for the events that are recorded (review notes, evidence timeline rows, webhook audit rows). They live until manually deleted.
  • No central log aggregation. pm2 logs stay on the VM; they are not shipped to Cloud Logging today.
  • No alerting on suspicious application events (failed-login spikes, privilege-escalation attempts, mass-export patterns).
  • The "audit-trail" endpoint name is misleading — it's an evidence-processing timeline, not a security audit. Renaming it is queued.

Roadmap

  • audit_log table plus append-only Postgres trigger as described above. This is the highest-priority application-security follow-up named on this page.
  • Inserts at every security-relevant code path with a documented event-type vocabulary.
  • Org-scoped read endpoint for the audit log.
  • Cloud Logging integration for application logs so pm2's local rotation isn't the retention story.
  • Alerting on a defined event-type set (failed-login spikes, cross-org reads by super-admins, API-key creation outside a documented window).
  • Rename the per-claim audit-trail endpoint to evidence-timeline to free the term "audit" for the actual audit log when it ships.