Repository navigation
feat(security): audit log detections, alert rules that count audit events - #2532
Conversation
…wn events One warehouse table for audit events from every connected platform, normalized to who did what to which target with what outcome. This first layer adds the table, the read path and the first writer. - audit_trail_events (migration 0064, local schema v56): ReplacingMergeTree keyed on a hash of provider, account and the provider's event id, so a redelivery collapses. A version column makes the first write win, so a redelivered event keeps its first IngestedAt. 400-day TTL, minmax index on IngestedAt. Written through ingest only, pinned to the managed warehouse and hidden from raw SQL like audit_log. - AuditTrailService: write (what every provider adapter calls) and list. - GET /internal/audit-trail/events: session only, behind audit_log:read, keyset paginated on (OccurredAt, Id), filters for provider, account, category, outcome, action, actor and time. A Maple member's email also finds their events. - Maple's own changes and refusals are copied in by the audit events consumer and the direct write path. Allowed reads, tool calls and SQL runs stay in audit_log. A failed copy is logged and dropped so it never sends stored entries to the DLQ.
…edential actions, review tests - The direct write path drops a failed audit_log insert at Warn; its trail copy now does the same instead of leaving an Error span. - Removing a status page password, revoking a break-glass token and unlinking a chat identity are filed like the actions that set them. - An email actor filter is resolved against Maple's members only when Maple's events can be in the result. - Tests: a trail copy that dies still acks, one that is interrupted stays unacked, the directory is read once per request, an empty batch never reaches the warehouse.
…, show the first write - Maple's events are stored under the user id, so an email filter needs the member directory. A directory outage now answers 503 instead of a page that silently leaves the member's Maple events out. A deployment with no directory is unchanged. - Unmerged copies of one event are read oldest write first, so the list shows the IngestedAt the table will keep.
/security/audit-log lists the unified audit trail, newest first, for org admins. - A new Activity section in the Security rail links to it. The page reads the session's grants first: without audit_log:read it says who can read the log and asks the API for nothing. - Events are grouped by day. A row shows when, the platform, who acted, the platform's action name with its category, the target and a mark for a refusal. Opening a row shows everything recorded about the event and its payload. - Category, outcome and actor filters live in the URL. The platform filter appears once a second platform writes events. - Load more continues from the last event shown. A failed page keeps the list and offers a retry. - jsonBlock moves from the settings audit log into the page kit, and both pages use it. - Six lab scenarios under /lab/security?state=audit-log* and a scene test.
…tries under their public id - OutcomeReason holds why an event failed or was refused, in the platform's words, as its own column instead of inside the payload. Maple's denial reason goes there. - A Maple entry's provider event id is its public alog_ id, the one the Settings audit log shows and looks up, so an id copied from one page finds it in the other. The table has no rows anywhere yet, so the column lands in migration 0064 itself.
… and message Both become public tagged errors, like the integrations group's: an outage now reads "The audit log is temporarily unavailable" on the page instead of a generic failure, and a refusal carries a code the page can tell apart from an outage.
- A next page is neither asked for nor accepted while other filters are loading, and an answer has to match the cursor it was asked from. - A session read that fails no longer locks an admin out: the API is asked and its refusal is what shows the admins-only state. - The rows dim while other filters load. - Nothing the row cuts off is lost: full values on hover, and the detail lists the action, actor, actor type and target. The refusal reason shows under the action. - Anything but a success is marked, so an unknown outcome does not read as one. - The outcome filter offers what a platform writes today. A footnote says allowed reads are in the Settings audit log, not here. - Kit: tableGroupRow (the releases list moves onto it), dayEyebrow, refusedBadge and expandChevron (the settings audit log moves onto them), prettyJson, and a controlled value on nativeSelect so a filter change keeps keyboard focus. - The page entry takes the auth mode as an argument so the hosted gate is tested.
Its label replaces its content for a screen reader, and the content was the only place the time was. The label now carries the action and the timestamp.
A screen reader heard "All outcomes, Denied": the select was named after its clear option. They are now Category and Outcome. The scene test's second page also carries a cursor on its own last event instead of the first page's.
Found testing on the preview: a key refused for a missing scope was filed under authentication with an empty target, so filtering refusals by permission found nothing and the row did not say what was asked for. It is now a permission event whose target is the request's method and path.
- An empty answer to the previous filters no longer shows as "no matches" while the new filters load: the list shows loading rows until they answer. - Row times tick: the clock is read every minute. - A Maple event's id links to the same entry in the Settings audit log, and the Settings lookup field now opens an entry when its alog_ id is pasted. - The footnote opens the Data access tab, where the reads it names are. - The lab fixture records a refused request the way the API now does.
… audit log filter The field was rebuilt from the URL on every route change, so typing an actor and then picking a category threw the text away. It now follows the URL only when it holds nothing unapplied, which keeps Back clearing an applied actor.
… window Retesting on the preview showed the Settings lookup change did not work: that page cannot fetch an entry by id alone, so pasting an alog_ id set the URL and opened nothing. The lookup is back to what it was. The link from the Security page now goes through the same builder as the Settings page's own entry links (the minute ending at the entry, changes and reads both shown), so it finds old entries too.
…t to logs An audit event names a person, their email and their IP address. As a log it was readable by every member and writable by anything holding an ingest key. The gateway now writes these to audit_trail_events, behind audit_log:read. Gateway (apps/ingest): - New audit_trail module: Vercel audit drain events and GCP Admin Activity and Policy Denied entries as rows, with the same key derivation as the TypeScript writer. GCP System Event entries are Google's own actions and stay logs. - The Vercel audit drain has its own handler. It refuses the public ingest key, which ships in browser bundles, and acks and drops while the integration's source is paused. - A GCP audit entry is stored ahead of the plan check on logs and is not metered. - A gateway that only forwards has no such table: there both feeds stay logs. Web: - The audit log page gets its platform filter, column and detail row back, with Vercel and Google Cloud named. Target shows on wide screens only, so Outcome stays on screen beside the rail. - /infra/vercel no longer reads audit events from logs. Its rail points at the Security audit log when the drain is on. The query, endpoint and row schema behind the old panel are removed. - The Vercel and GCP setup copy and the public docs say where these events land.
…ded and off by default Review of the Vercel and GCP layer: - The Vercel audit drain accepted any private key or source token. It now takes only the drain Maple creates: the private key naming one of the org's integration sources. - A delivery is decoded inside the shared decode memory budget and capped at 4 MiB; a small gzip could otherwise parse into gigabytes. - Audit rows are unmetered, so each org has a delivery rate, every column a cap, and a timestamp outside the table's retention is replaced by the time of receipt. - Kubernetes API writes stay logs. Categories are matched on whole words, a federated workload is a named service, and Vercel rows carry a target. - INGEST_AUDIT_TRAIL_ENABLED (default off) turns the trail on, so the gateway can deploy before the table exists and is switched off rather than rolled back past the new WAL frame tag. - The handler records request metrics like the other ingest routes. - Tests read the stored row instead of the returned count.
… to retry verify_source_claims left a claim unverified when the store could not be asked, and the audit handler read that as an unknown sender (401). The drain then looked misconfigured during a Postgres blip. The lookup now reports the failure and the handler answers 503, as the key lookup does.
…n and the count query An audit log detection is a managed alert rule with signal type audit: N or more matching events arrived in the last W minutes. This adds the wire schemas and endpoints on the audit trail API, the nullable audit_filters_json column on alert_rules, and auditTrailCountQuery, which counts distinct event ids by IngestedAt and bounds OccurredAt so partitions prune.
…ents A detection is a hidden alert rule with signal type audit, written only through the audit trail API by callers who hold audit_log:read. AuditDetectionsService owns the rule row the way an SLO owns its burn-rate rule: destinations and the name are checked under the org's alert rule lock, an edit ends the tick's claim, and a delete takes the rule's state, deliveries and incidents with it. The evaluator counts the events that arrived in the rule's window through AuditTrailService.count. A count is a fact even at zero, so an empty window is healthy and resolves, and a count that could not be read is an evaluation failure that leaves an open incident open. Notifications carry the count as their detail and never an event.
The rule fields and the row read back; the detections service over PGlite (create, list, update, remove, the cap, an unknown destination, a taken name, another org's ids); the count query through the fake warehouse; a scheduler tick that stays healthy below the count, opens at it, resolves below it and holds an open incident while the count cannot be read; and the routes, where a caller without audit_log:read is refused on every endpoint.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 43 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
This comment has been minimized.
This comment has been minimized.
… who can open it Every member can read an incident, and the audit log needs audit_log:read. A member who followed the link was sent home with no explanation. The note now says only org admins can open the log, and the link shows for them.
This comment has been minimized.
This comment has been minimized.
- An audit incident opened an issue on the Errors page, offered a root-cause run over telemetry, and the issue outlived the detection. An audit detection now files no issue. - The public API hid a detection's rule but still served its checks by id: a count of audit activity for every minute. Those now answer 404 there. - A new detection, or one whose window was widened, fired at once on events that were already in the audit log. It now counts only what arrived since it was last saved. - The duplicate name message says detection, not only alert rule.
# Conflicts: # packages/views/src/ui/INDEX.md
Main gained migrations on both sides of the two this branch added, which left the folder with two heads. The two columns now arrive in one migration after them.
Main added the cost source and its provider key as audited resources. A key is a credential, connecting a source is configuration.
This comment has been minimized.
This comment has been minimized.
…ot a failed write The consumer drops a failed copy and acks the entry, but wrote it without bestEffort, so the insert closed its spans as Error for a copy that was discarded on purpose. The direct write path already passed it.
This comment has been minimized.
This comment has been minimized.
… filtered empty state A phone drops the Platform column, so another platform's name now leads the line under the action. The empty state for a filter that matched nothing offers the platform filter too. The drains note says where audit events are when the gateway does not store the trail.
This comment has been minimized.
This comment has been minimized.
# Conflicts: # packages/domain/src/http/index.ts
# Conflicts: # apps/web/src/page/alerts/incident/view.ts # apps/web/src/page/kit/session-grants.ts # packages/views/src/ui/INDEX.md
This comment has been minimized.
This comment has been minimized.
Maple review🟢 Confidence 10/10 · safe to merge. The delta since the last review is only base-branch merges this PR does not modify; its own content is unchanged since d60d7b5 and has no open findings. Nothing new to review: the four files changed since the previous review are base-branch merges of shared UI tokens, sidebar and table styles that Before merge
|
|
Merged with 1 step from "Before merge" still open. If it is done, tick it on the review comment; if not, now is the time.
|
Fourth layer of the unified audit log, on top of #2530: detections. An org admin can alert on the audit log.
What a detection is
"N or more events matching these filters arrived in the last W minutes." It is an alert rule of a new managed signal type,
audit, so it gets the alert pipeline as it is: incidents, severities, destinations, silences, the Alerts hub inbox.It is managed the way an uptime monitor's or an SLO's rule is. It cannot be created, edited or listed through the generic alert rule APIs, the alert editor, the MCP tools or the infrastructure-as-code provider. It is written only through new endpoints on the audit trail API, and those need
audit_log:read, which only org admins hold.What it may and may not carry
Alert rules and incidents are synced to every member's browser and sent to shared destinations, so a detection is built to hold nothing a member may not see:
What a member does see: the detection's name, its threshold and that it fired.
How it counts
IngestedAt), not when they happened, so an event a provider delivers late still alerts.OccurredAtis bounded to a week before the window so partitions prune.alert_rules.audit_count_from): the time it was created, moved forward when its filters change, its window widens or it is switched back on. So a new detection, or one that now counts more, does not fire on what was already in the log, and a rename or a new severity does not disturb an open incident.Changes
auditinAlertSignalTypeandMANAGED_SIGNAL_TYPES;AuditDetectionFilters; the detection wire schemas and four endpoints under/internal/audit-trail/detections.alert_rules:audit_filters_json(migration20261112150508_alert_rules_audit_filters) andaudit_count_from(20261112173647_alert_rules_audit_count_from).auditTrailCountQuery, routed to the managed warehouse like the list query.AuditDetectionsService(list, get, create, update, remove; 50 per org; destinations and the name checked under the org's alert rule lock),AuditTrailService.count, and a branch inAlertsService.evaluateRulebeside the other managed types.audit_log:read; each change is recorded in Maple's own audit log asalert_rule.created/updated/deleted.audit: labels, the v2 read-scope switch, the incident page, the iOS formatter and the generated iOS OpenAPI file, the webhook reference.There is no page for this yet. The Detections page in the Security hub is the next layer.
Worth knowing before merging
/v2response that contains anauditincident.anomalyandslo_burn_ratehad the same exposure when they were added. Nothing creates anauditrule until the page ships.Verified
AlertsService.audit.test.ts, 56 tests; 25 alert, audit and anomaly files, 342 tests; the service against PGlite covers create, list, update, remove, the cap, an unknown destination and a duplicate name.AlertsService, 62.gen:anticipated-errors:check,ios:openapi:checkandui:patterns:checkare up to date.oxlint -c .oxlintrc.effect.jsonis clean on the changed files.bun typecheckpasses, 57 of 57.Tested on the
preview:webdeploy of #2534 by a separate tester, signed in as an org admin: a detection made from the credential template opened an incident 50 seconds after an API key was created, withsignal_typeaudit, the observed count and nothing else (no email, name, id, IP or action in the incident, its events, checks or deliveries); it resolved on its own 5 min 42 s after the last matching event; switching it off resolved an open incident within 50 seconds. The rule was absent fromGET /v2/alerts/rules, and reading, updating or deleting it by id answered 404. That test found the three behaviours fixed ince4b6d6427(the Errors page issue, the checks endpoints, firing on events from before the save).A second pass on the preview at
8d7ab1f7afconfirmed each fix live: a detection created 34 seconds after a matching event opened nothing over 13 polls; the next event opened an incident with an observed value of 1, not 2; a rename and a severity change left that incident open through seven polls; no issue appeared on the Errors page (error_issue_idnull); and both checks endpoints answered 404alert_rule_not_found. Incidents opened 66 and 78 seconds after the event.Two things that pass recorded, which follow from the design: widening the window while an incident is open moves the floor, so the incident resolved (observed 0) and did not reopen on the event that was still inside the new window; and an open incident keeps the name its detection had when it opened.
Not verified: the Swift change is not compiled. The count query has not run against a real ClickHouse, only compiled and decoded through the fake warehouse.