Skip to content

feat(audit): org-wide audit log with actor attribution and durable queue delivery - #683

Open
Makisuo wants to merge 6 commits into
mainfrom
feat/org-audit-log
Open

feat(audit): org-wide audit log with actor attribution and durable queue delivery#683
Makisuo wants to merge 6 commits into
mainfrom
feat/org-audit-log

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

What

An org-wide, append-only audit log: every allowed or denied action performed against Maple — from the dashboard, the public API, or MCP — attributed to the user, API key, or agent that performed it.

How it works

Data model (audit_log_entries, migration 0050)

  • Actor snapshot at write time: type (user/api_key/agent/system), credential refs (user_id/api_key_id/actor_id), frozen display label, and on-behalf-of user for agents.
  • outcome + denial_reason — denied attempts (scope rejections, org-selection widening, wrong-surface key use) are recorded from inside the auth layers with the same attribution as successes.
  • Before/after changes diffs on updates, with touched field names in a queryable changed_fields[] column. Secrets are redacted at write time; large config blobs are summarized.
  • Request forensics: request_id (cf-ray), origin IP + country, affected_user.
  • occurred_at (producer) vs recorded_at (consumer at insert).

Durability — audit events flow through a new Cloudflare Queue (audit-events): producers enqueue, the api worker consumes batches and inserts idempotently ((org_id, id) PK + onConflictDoNothing, so redelivery can't duplicate). Queue missing or send failing degrades to a direct Postgres write; only if that also fails is the entry dropped (logged). Declared in apps/api/alchemy.run.ts (queue + producer binding + consumer, batch 25 / 5 retries) and mirrored in wrangler.jsonc so miniflare runs it in-process locally.

Recording surfaces

  • Every v2 mutation handler (alert_rule.*, api_key.*, dashboard.*, scrape_target.*, attribute_mapping.*, ingest_key.rolled, anomaly_*), with update diffs.
  • The issue-workflow choke point: all issue transitions/comments/claims from web or MCP, with real agent-vs-user attribution.
  • register_agent, and auth-layer denials (api.request / outcome: denied).

Read sideGET /v2/audit_log: cursor-paginated, filterable by actor_type, actor_id, affected_user, action, outcome, resource_type, resource_id, changed, request_id, and time window. New audit_log:read scope, alog_ public IDs.

UI — Settings → Audit Log: actor + outcome filter pills, denied badges with reason, change summaries with before→after tooltips, origin/request detail, load-more pagination.

Retention — hourly sweep in the api worker's existing retention cron deletes entries past AUDIT_LOG_RETENTION_DAYS (default 400), in bounded batches.

Notes for deploy

  • Migration 0050_audit_log_entries must be applied manually to prd (standard process).
  • Optional hardening (manual, not in this PR): an insert/select-only Postgres role for the table per append-only best practice:
    CREATE ROLE audit_writer; GRANT INSERT, SELECT ON audit_log_entries TO audit_writer;

Testing

  • Full typecheck green across domain / db / api / web / alerting.
  • 120 v2 contract/OpenAPI tests; 847 api tests across 73 files (routes, auth, errors, MCP, runtime graphs); audit service tests cover record/list roundtrip, filters (actor, outcome, changed field), queue-send vs direct-write paths, and idempotency; migration journal invariants pass.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…eue delivery

Adds an append-only audit trail distinguishing users, API keys, agents,
and system automation, following the auditlog.dev spec:

- audit_log_entries table (migration 0050): actor snapshot + credential
  refs, outcome (allowed/denied) + denial_reason, before/after change
  diffs with queryable changed_fields, affected_user, request forensics
  (request id, origin IP/country), and occurred_at/recorded_at.
- Durable delivery through a Cloudflare Queue (audit-events): producers
  enqueue, the api worker consumes and inserts idempotently; direct DB
  write as fallback when the binding is absent or the send fails.
- CurrentAuditActor context from all three auth layers distinguishes
  session vs API-key requests; denied attempts (scope/org/surface
  rejections) are recorded from inside the auth layers.
- Recording wired into every v2 mutation handler (with diffs and
  secret redaction), the issue-workflow choke point (agent vs user
  attribution with on-behalf-of), and register_agent.
- GET /v2/audit_log: cursor-paginated, filterable by actor, outcome,
  action, resource, changed field, request id, and time window; new
  audit_log:read scope and alog_ public IDs.
- Settings > Audit Log tab with actor/outcome filters, denied badges,
  change summaries, and load-more pagination.
- Hourly retention sweep (AUDIT_LOG_RETENTION_DAYS, default 400) in the
  api worker's existing retention cron.
- Tagged AuditQueueSendError instead of a global Error in the queue send
  failure channel.
- compactAuditChanges takes static placeholder strings instead of
  unknown-typed summarizer functions; null still survives as null.
- destinationObservableValue returns a concrete union, not unknown.
- iOS OpenAPI spec regenerated for the new /v2/audit_log path.
…, indexes

From four review passes over the audit log:

- Admin-gate GET /v2/audit_log: entries carry every member's activity,
  denial history and origin IP for the retention window. The settings
  tab hides for non-admins to match.
- Coalesce denied api.request records per (org, key, method+path,
  reason) in a 60s isolate-local window. The v1 auth layer has no rate
  limiter, so a client looping mis-scoped requests could otherwise
  amplify into unbounded queue messages, rows and warn logs. Both auth
  layers now share one helper, so v1 records the same forensics as v2.
- Audit the two v2 denial branches that returned early (MCP-only key,
  invalid device credential) — the credential-probing case the feature
  exists to surface.
- Bound queue.send with a 2s timeout: a stalling broker must not hang
  the mutation's response before the direct-write fallback.
- Replace blanket catchCause with catchTag/catchDefect so interruption
  propagates instead of spawning a Postgres insert mid-teardown.
- Structural (key-order insensitive) diff comparison; redact userinfo
  and query strings from audited scrape-target URLs; carry the cause on
  AuditLogPersistenceError.
- Index occurred_at for the retention sweep, the actor-identity columns
  for the primary 'what did this credential do' query, and a GIN index
  for changed-field lookups.
…ields

Adding an audited action meant restating what the action already implied:
a free-string `resourceType` echoing the action's own prefix, an inline
`encodePublicId(PublicIdPrefixes.x, id)`, and — for updates — a
hand-assembled diff pipeline. Across 28 call sites the resource pair was
mechanically derivable every time, and nothing checked it: the service's
own test recorded `alert_rule.delete`, a verb that does not exist.

`AuditResources` now declares each resource with its public-ID prefix and
verbs, and `AuditAction` is the derived `${resource}.${verb}` union.
`record`/`recordHttpAudit` take the internal ID and derive `resourceType`
plus the public encoding themselves, so a typo fails the build, a
`resourceId` on an org-singleton resource fails the build, and the
prefix can no longer disagree with the resource. `error_issue` verbs come
from `ErrorIssueEventType.literals` so a new issue event type cannot
produce an undeclared action.

`auditDiff({ fields, summarize, redact, writeOnly })` replaces the
per-handler diff assembly; scrape-targets' update handler goes from ~40
lines of object surgery to one call. Keying `summarize`/`redact` by
`fields` makes the old "remember to `satisfies`" rule structural.
Durability. The audit-events consumer had no dead letter queue and no
final-attempt branch, so after five retries Cloudflare dropped the entry
with nothing in the logs at the moment it happened. There is now an
`audit-events-dlq` queue with no consumer — an entry landing there is a
lost record and the point is that it survives — and the consumer logs the
hand-off at Error, with the org and action read defensively off the body.
It keeps retrying on the final attempt, because acking is what would
discard the message instead of routing it.

Attribution. The actors row knows who acted, never how, so every mutation
reached through an API key or over MCP was recorded as a dashboard
session — the MCP middleware set no audit reference at all.
`CurrentAuditActor` now carries the surface alongside the credential, all
four auth layers stamp it, and the issue-workflow mirror consults it
instead of assuming. Maple's own sweeps run as an agent actor, which made
auto-close and lease expiry read as a third-party agent over MCP; they are
now recorded as `system`, which until today had no writer at all.

Coverage. Audited org deletion, warehouse settings (updated, deleted,
schema applied), the Slack and PlanetScale integration lifecycles
including the metrics-token install, widget credential mint/revoke,
investigations, and issue comments — which wrote their event row directly
and so bypassed the audit mirror entirely. Secrets stay out: the entries
record which credential was installed, never its value.

Membership. Members are changed in Clerk, never through Maple's API,
which is why `affected_user` had no writers. The Clerk receiver now
audits `organizationMembership.*` against the member. Clerk's payload
does not name the admin who acted, so the entry is attributed to
`system` rather than guessing a user. Enabling the three events in the
Clerk dashboard is what turns this on.

UI. The list paginates by offset over a newest-first append-only table,
so an entry written mid-scroll shifted later pages and made them repeat
one row and skip another. The first Load more now pins `until` to the
newest entry on screen, freezing the window, and pages are deduped by id
on append. The header no longer claims to record "every change".

The retention sweep's ctid-addressed delete already landed with the
review fixes; verified rather than changed.
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