Skip to content

feat(security): audit log detections, alert rules that count audit events - #2532

Merged
Makisuo merged 53 commits into
mainfrom
audit-trail/detections
Nov 13, 2026
Merged

Makisuo merged 53 commits into
mainfrom
audit-trail/detections

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • Filters are provider, category, outcome and an exact action. There is no actor or IP filter, so no rule names a person.
  • There is no grouping, so no incident key or notification title carries an email or an address.
  • An incident and its notifications say how many events matched and over which window. They never include an event. The incident page links to the audit log, which only admins can open.

What a member does see: the detection's name, its threshold and that it fired.

How it counts

  • Events are counted by when they arrived (IngestedAt), not when they happened, so an event a provider delivers late still alerts. OccurredAt is bounded to a week before the window so partitions prune.
  • Distinct event ids, so a redelivery inside the window counts once.
  • The count is a fact even at zero: an empty window is healthy, not "no data", so nothing is held open by the liveness probe.
  • Checked every minute. One check at the count opens the incident and one below it resolves it; a reminder goes out once a day while it stays open.
  • A detection counts from a floor (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.
  • An audit incident files no issue on the Errors page and starts no root-cause run: it is not a fault in a service.
  • The public API hides the rule, and its checks by id as well (404): they are a count of audit activity for every minute.

Changes

  • Domain: audit in AlertSignalType and MANAGED_SIGNAL_TYPES; AuditDetectionFilters; the detection wire schemas and four endpoints under /internal/audit-trail/detections.
  • Postgres: two nullable columns on alert_rules: audit_filters_json (migration 20261112150508_alert_rules_audit_filters) and audit_count_from (20261112173647_alert_rules_audit_count_from).
  • Warehouse: auditTrailCountQuery, routed to the managed warehouse like the list query.
  • Backend: 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 in AlertsService.evaluateRule beside the other managed types.
  • API: handlers gated on audit_log:read; each change is recorded in Maple's own audit log as alert_rule.created/updated/deleted.
  • Everything that enumerates signal types now knows 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

  1. Older iOS builds. The generated Swift enum is closed, so an installed build fails to decode a /v2 response that contains an audit incident. anomaly and slo_burn_rate had the same exposure when they were added. Nothing creates an audit rule until the page ships.
  2. Deleting a detection deletes its incidents with it, open ones included, and sends no "resolved" notification. That is what deleting an SLO or a rule does today.
  3. A redelivery that arrives after the window has passed counts again, because it has a later arrival time until the table merges it away. The effect is a second incident for the same event. Providers redeliver within seconds in practice.
  4. Migrations. Two, both additive and nullable; the prd deploy applies them.

Verified

  • Backend: the audit trail folder and 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.
  • API: the audit trail routes, 16 tests (a non-admin gets the forbidden error on every endpoint, an admin round-trips a detection); the v2 read-scope switch, 15; the alert routes that build AlertsService, 62.
  • Domain 1240 tests, db parity and migrations 17, query-engine catalog and SQL baseline, web-data alerts and collections 203, the incident page scene tests 18, the MCP descriptor and contract tests.
  • gen:anticipated-errors:check, ios:openapi:check and ui:patterns:check are up to date. oxlint -c .oxlintrc.effect.json is clean on the changed files. bun typecheck passes, 57 of 57.

Tested on the preview:web deploy 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, with signal_type audit, 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 from GET /v2/alerts/rules, and reading, updating or deleting it by id answered 404. That test found the three behaviours fixed in ce4b6d6427 (the Errors page issue, the checks endpoints, firing on events from before the save).

A second pass on the preview at 8d7ab1f7af confirmed 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_id null); and both checks endpoints answered 404 alert_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.

…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.
@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f5175ba6-91ca-4a9c-9453-b8462b67db53
Commits

Reviewing files that changed from the base of the PR and between 1ed18ed and 839c56f.

Files ignored due to path filters: 1

  • packages/domain/src/generated/anticipated-error-identifiers.ts is excluded by !**/generated/**
Files selected for processing: 84

  • apps/api/src/routes/internal/audit-trail.http.test.ts
  • apps/api/src/routes/internal/audit-trail.http.ts
  • apps/api/src/routes/internal/query-engine.http.ts
  • apps/api/src/routes/v2/alchemy-provider.integration.test.ts
  • apps/api/src/routes/v2/alert-rule-read-families.test.ts
  • apps/api/src/routes/v2/alert-rule-read-families.ts
  • apps/api/src/routes/v2/alert-rules.http.ts
  • apps/api/src/routes/v2/alerts.http.test.ts
  • apps/api/src/runtime/service-graph.ts
  • apps/ingest/alchemy.run.ts
  • apps/ingest/alchemy/gateway-env.ts
  • apps/ingest/src/audit_trail.rs
  • apps/ingest/src/lib.rs
  • apps/ingest/src/main.rs
  • apps/ingest/src/telemetry.rs
  • apps/ios/Maple/Components/AlertFormatting.swift
  • apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
  • apps/landing/src/content/docs/integrations/gcp.md
  • apps/landing/src/content/docs/integrations/vercel.md
  • apps/landing/src/content/docs/reference/webhooks.md
  • apps/web/src/lab/infra-lab/update.ts
  • apps/web/src/lab/infra-lab/view.ts
  • apps/web/src/lab/security-audit-log-fixture.ts
  • apps/web/src/lab/vercel-infra-fixtures.ts
  • apps/web/src/page/alerts/incident/incident.scene.test.ts
  • apps/web/src/page/alerts/incident/view.ts
  • apps/web/src/page/infra-integrations/gcp/integration/gcp-configure-copy.ts
  • apps/web/src/page/infra-integrations/gcp/integration/view-flow-gcp.ts
  • apps/web/src/page/infra-integrations/gcp/integration/view-gcp-configure-filter.ts
  • apps/web/src/page/infra-integrations/vercel/data.ts
  • apps/web/src/page/infra-integrations/vercel/integration/vercel.scene.test.ts
  • apps/web/src/page/infra-integrations/vercel/integration/view-vercel-configure.ts
  • apps/web/src/page/infra-integrations/vercel/integration/view-vercel.ts
  • apps/web/src/page/infra-integrations/vercel/list/command.ts
  • apps/web/src/page/infra-integrations/vercel/list/model.ts
  • apps/web/src/page/infra-integrations/vercel/list/update.ts
  • apps/web/src/page/infra-integrations/vercel/list/vercel.scene.test.ts
  • apps/web/src/page/infra-integrations/vercel/list/view-rail.ts
  • apps/web/src/page/infra-integrations/vercel/sections.ts
  • apps/web/src/page/kit/session-grants.ts
  • apps/web/src/page/security/audit-log/audit-log.scene.test.ts
  • apps/web/src/page/security/audit-log/command.ts
  • apps/web/src/page/security/audit-log/derive.ts
  • apps/web/src/page/security/audit-log/model.ts
  • apps/web/src/page/security/audit-log/view.ts
  • apps/web/src/page/security/kit/layout.ts
  • apps/web/src/route/search.ts
  • packages/backend/src/queries/registry.ts
  • packages/backend/src/services/alerts/AlertDeliveryDispatch.test.ts
  • packages/backend/src/services/alerts/AlertDeliveryDispatch.ts
  • packages/backend/src/services/alerts/AlertReadModelsService.test.ts
  • packages/backend/src/services/alerts/AlertReadModelsService.ts
  • packages/backend/src/services/alerts/AlertRuleModel.ts
  • packages/backend/src/services/alerts/AlertsService.audit.test.ts
  • packages/backend/src/services/alerts/AlertsService.ts
  • packages/backend/src/services/alerts/alert-formatting.ts
  • packages/backend/src/services/alerts/alert-signal-display.ts
  • packages/backend/src/services/alerts/alerts-service-test-support.ts
  • packages/backend/src/services/audit-trail/AuditDetectionsService.test.ts
  • packages/backend/src/services/audit-trail/AuditDetectionsService.ts
  • packages/backend/src/services/audit-trail/AuditTrailService.test.ts
  • packages/backend/src/services/audit-trail/AuditTrailService.ts
  • packages/backend/src/services/audit-trail/audit-detection-rule.test.ts
  • packages/backend/src/services/audit-trail/audit-detection-rule.ts
  • packages/db/drizzle/20261113122938_alert_rules_audit_detection/migration.sql
  • packages/db/drizzle/20261113122938_alert_rules_audit_detection/snapshot.json
  • packages/db/src/tables/alerts.ts
  • packages/domain/src/audit-trail.ts
  • packages/domain/src/http/alerts.ts
  • packages/domain/src/http/audit-trail.ts
  • packages/domain/src/http/query-engine.ts
  • packages/query-engine-integrations/src/__sql_baseline__/integrations.sql
  • packages/query-engine-integrations/src/benchmark/index.ts
  • packages/query-engine-integrations/src/vercel/index.ts
  • packages/query-engine-integrations/src/vercel/vercel-event-drains.ts
  • packages/query-engine-integrations/src/vercel/vercel.test.ts
  • packages/query-engine/src/__sql_baseline__/catalog.sql
  • packages/query-engine/src/benchmark/builders.ts
  • packages/query-engine/src/ch/queries/audit-trail.ts
  • packages/views/src/ui/INDEX.md
  • packages/web-data/src/alerts/form-utils.ts
  • packages/web-data/src/alerts/overview.test.ts
  • packages/web-data/src/collections/alerts.ts
  • packages/web-data/src/warehouse/vercel-infra.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Share

@maple-review-bot

This comment has been minimized.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

… 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.
@maple-review-bot

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.
@maple-review-bot

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.
@maple-review-bot

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.
@maple-review-bot

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
@maple-review-bot

This comment has been minimized.

@maple-review-bot

maple-review-bot Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

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.
no findings · tests covered · risk high

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 d8dd5ff..839c56f does not touch. The detection work itself is unchanged since d60d7b5 and no earlier finding remains open.

Before merge

839c56f · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

Base automatically changed from audit-trail/vercel-gcp to main November 13, 2026 13:51
@Makisuo
Makisuo merged commit 6a14c3f into main Nov 13, 2026
43 of 56 checks passed
@Makisuo
Makisuo deleted the audit-trail/detections branch November 13, 2026 13:51
@maple-review-bot

Copy link
Copy Markdown

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.

  • Migration · Check it is safe on production data and with the code still deployed

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