feat(audit): org-wide audit log with actor attribution and durable queue delivery - #683
Open
Makisuo wants to merge 6 commits into
Open
feat(audit): org-wide audit log with actor attribution and durable queue delivery#683Makisuo wants to merge 6 commits into
Makisuo wants to merge 6 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, migration0050)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.changesdiffs on updates, with touched field names in a queryablechanged_fields[]column. Secrets are redacted at write time; large config blobs are summarized.request_id(cf-ray), origin IP + country,affected_user.occurred_at(producer) vsrecorded_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 inapps/api/alchemy.run.ts(queue + producer binding + consumer, batch 25 / 5 retries) and mirrored inwrangler.jsoncso miniflare runs it in-process locally.Recording surfaces
alert_rule.*,api_key.*,dashboard.*,scrape_target.*,attribute_mapping.*,ingest_key.rolled,anomaly_*), with update diffs.register_agent, and auth-layer denials (api.request/outcome: denied).Read side —
GET /v2/audit_log: cursor-paginated, filterable byactor_type,actor_id,affected_user,action,outcome,resource_type,resource_id,changed,request_id, and time window. Newaudit_log:readscope,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
0050_audit_log_entriesmust be applied manually to prd (standard process).Testing
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.