Skip to content

feat(security): a Detections page for audit log detections - #2534

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

Makisuo merged 73 commits into
mainfrom
audit-trail/detections-page

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fifth layer of the unified audit log, on top of #2532: the Detections page. An org admin can now create the alerts the previous layer made possible.

What it adds

  • Detections in the Security hub rail, under Audit log (/security/detections). Admins only, with the same three access states as the audit log: asking, allowed, and an admins-only notice.
  • The list. Each row says what the detection matches in words, when it fires ("5 or more in 10 minutes"), its severity and who it notifies. A detection that notifies no one is marked, because it opens incidents nobody is told about. The switch, edit and delete act on the API's answer; nothing is flipped optimistically. Delete says that the detection's incidents go with it.
  • The empty state is the on-ramp. Five ready-made detections (member added or removed, permission or role changed, credential changed, a burst of refused requests, repeated sign-in failures). Choosing one opens the editor filled in; nothing is created until it is saved. The same starting points are in the editor's "Start from" select.
  • The editor is a form page like the SLO and uptime monitor editors, with the same destination picker. It checks the request against the API's own schema before sending and shows the server's message for a duplicate name, an unknown destination or the cap.
  • From the audit log. With filters applied, "Alert on these events" opens the editor with the platform, category and outcome carried over. The actor filter is not carried, and the label says so ("from any actor"): a detection cannot name a person.
  • Docs: a section on the alert rules page saying what a detection is, who can create one and what members see.

The page states once what the backend guarantees: every member can see a detection's name and that it fired; only org admins see the events it counted.

Screenshots

The list list
No detections yet empty
New detection editor
The server refuses a duplicate name error
A member member
Light theme light
Phone width

Audit log with filters applied. The "before" is this build with the control switched off, so its rail already shows the new entry.

Before After
before after

Verified

  • Web: the lab, the Security pages, the routes and the sample-URL list, 36 files and 456 tests. The two new suites have 19 tests (list) and 17 (editor): the access states, what each row says, the on-ramp opening a prefilled editor, create and edit against a stubbed API, the toggle, delete with confirmation, the server's invalid message, and the audit log control carrying the filters.
  • bun run --cwd apps/web typecheck, ui:index:check and ui:patterns:check pass. oxlint -c .oxlintrc.effect.json is clean on the changed files.

Tested on this PR's preview:web deploy by a separate tester, signed in as an org admin, against the real API:

  • The empty state, creating from a template, editing (the new condition survives a reload), the switch (both states survive a reload), a duplicate name (the server's message in the form, nothing created), a count of 0 and a window of 3 minutes (refused before sending), delete (cancel does nothing, confirm removes it and its incidents), and "Alert on these events" carrying the category and outcome and dropping the actor.
  • Keyboard only: every control in the editor is reachable with a visible focus ring. Back and Forward between the list, the editor and the audit log. A nonsense id shows "Detection not found".
  • It found four defects in the page, fixed in 9b9aaf6bef: the list hid its row controls below about 1340 px, the audit log control ran off a phone screen, "A blank detection" did not blank the form, and the server's refusal stayed after the form was corrected.
The list at 1280 wide, after the fix
1280

A second pass on the preview at 8d7ab1f7af confirmed the two layout fixes live: at 1280 x 800 the switch, Edit and Delete sit inside the panel with no sideways scroll, and at 390 wide the audit log control fits with a category and an actor applied.

Not testable on a web-only preview (no live sync): picking destinations in the editor, the Alerts hub and the incident page. The destination picker is the one the SLO and uptime editors use. A non-admin's view was not tested either: the preview has one account.

Known rough edges, left as they are: a failed load still shows the "New detection" button; a name that differs only in case from an existing one is accepted; an org with no alert destinations gets no link to where they are made.

…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.
The Security hub lists the org's detections under Activity, with a switch that saves on toggle, edit and a confirmed delete. With none set up the page offers five ready-made ones. The editor is a form page in the shape of the SLO editor, with the same destination picker, and the audit log opens it prefilled from its platform, category and outcome filters. The audit log's label maps and access check move to the hub's kit so both pages share them.
Lab states for the list, the empty on-ramp, the member view, a failed load, and the editor new, from a template, on an existing detection and with a server error, plus a filtered audit log. Scene tests cover the three access states, what each row says, the on-ramp, create and edit against the API's answers, the toggle, the confirmed delete, the invalid error's message and the audit log's prefill link.
@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 42 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: 0720c772-d64a-4da9-844d-41ec527d32d1
Commits

Reviewing files that changed from the base of the PR and between 6a14c3f and d20e687.

Files selected for processing: 36

  • apps/landing/src/content/docs/alerting/alert-rules.md
  • apps/web/src/lab/entries/security.ts
  • apps/web/src/lab/security-detections-fixture.ts
  • apps/web/src/lab/security-lab/scenarios.ts
  • apps/web/src/page/registry.ts
  • apps/web/src/page/security/audit-log/audit-log.scene.test.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/detection-form/command.ts
  • apps/web/src/page/security/detection-form/derive.ts
  • apps/web/src/page/security/detection-form/detection-form.scene.test.ts
  • apps/web/src/page/security/detection-form/form.ts
  • apps/web/src/page/security/detection-form/index.ts
  • apps/web/src/page/security/detection-form/model.ts
  • apps/web/src/page/security/detection-form/update.ts
  • apps/web/src/page/security/detection-form/view.ts
  • apps/web/src/page/security/detections/command.ts
  • apps/web/src/page/security/detections/derive.ts
  • apps/web/src/page/security/detections/detections.scene.test.ts
  • apps/web/src/page/security/detections/index.ts
  • apps/web/src/page/security/detections/model.ts
  • apps/web/src/page/security/detections/update.ts
  • apps/web/src/page/security/detections/view.ts
  • apps/web/src/page/security/kit/audit-access.ts
  • apps/web/src/page/security/kit/audit-labels.ts
  • apps/web/src/page/security/kit/detection-api.ts
  • apps/web/src/page/security/kit/detection-format.ts
  • apps/web/src/page/security/kit/detection-templates.ts
  • apps/web/src/page/security/kit/layout.ts
  • apps/web/src/route/app-route.ts
  • apps/web/src/route/redirect.ts
  • apps/web/src/route/route.test.ts
  • apps/web/src/route/routers.ts
  • apps/web/src/route/search.ts
  • apps/web/src/view.lazy.test.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.

@Makisuo Makisuo added the preview:web Deploy a Workers-only PR preview (no ingest/Electric); see docs/pr-previews.md label Nov 12, 2026
@Makisuo
Makisuo deployed to pr-preview November 12, 2026 16:38 — with GitHub Actions Active
@maple-review-bot

This comment has been minimized.

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

The label and its actor caveat were separate flex items, so from the width where
the caveat shows, the button's gap fell before the comma. They are one text run now.
# 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.

…tion

An id that is not one showed "Detection not found" under the heading "New detection".
The heading now says it is a link to a detection, as it does for one the org no longer has.
@maple-review-bot

This comment has been minimized.

@maple-review-bot

This comment has been minimized.

Base automatically changed from audit-trail/detections to main November 13, 2026 13:51
@maple-review-bot

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

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge. Only the delta since the previous review was new, and it is empty: both integration-statuses files now match main byte for byte.
no findings · tests covered · risk medium

Adds the Detections list and editor for audit-log detections, its templates and the audit-log "alert on these events" link. Nothing new to review since the last pass: git diff febc3e8..d20e687 touches only apps/web/src/data/integration-statuses.ts and its test, and both are now identical to main (the merge took upstream's version), so they are not in this PR's diff. The McpQueryError issues anchored at apps/web/src/page/registry.ts come from MCP tool warehouse queries; this change only registers two page routes there and does not address them.

  • detections/page lists rules with toggle, edit and confirmed delete against the API's answer
  • detection-form/page creates, edits and prefills from five ready-made templates
  • SecurityDetectionNewSearch carries detectionId, template and the audit log's filters into the editor
  • audit-access keeps the three access states shared with the audit log

Production impact

Open errors in the changed files (2)
Issue Service Occurrences File
@maple/mcp/errors/McpQueryError (7 issues) maple-ai 33 apps/web/src/page/registry.ts
@maple/mcp/errors/McpQueryError (3 issues) maple-chat 15 apps/web/src/page/registry.ts

After this merges, Maple checks whether they stop.

d20e687 · 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.

@Makisuo
Makisuo merged commit 9ce50c8 into main Nov 13, 2026
29 checks passed
@Makisuo
Makisuo deleted the audit-trail/detections-page branch November 13, 2026 14:04
@maple-review-bot

Copy link
Copy Markdown

Maple: after this shipped

✅ Shipped clean. Nothing this change touches got worse in the hour after it deployed.

Deployed to maple-chat (production) at 2026-11-13 14:04 UTC as 6a14c3f, the first version after the merge (the merge commit itself never reported). Compared the 60 minutes before and after.

Errors in the changed files:

  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h
  • ➖ @maple/mcp/errors/McpQueryError: 0/h → 0/h

Maple looks once, an hour after the first deploy that carries the merge.

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