Skip to content

feat(security): the audit log page in the Security hub - #2520

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

Makisuo merged 27 commits into
mainfrom
audit-trail/page

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Second layer of the unified audit log, on top of #2510: the page that shows it.

What changed

  • /security/audit-log, linked from a new Activity section in the Security rail. Events are newest first and grouped by day. Each row shows when, who acted, the platform's action name with its category, the target, and a mark for anything that did not succeed, with the reason under the action.
  • Admins only. The page reads the session's grants before anything else. Without audit_log:read it says who can read the log and asks the API for nothing. If the session read fails, the API is asked and its refusal decides, so an admin is never locked out by a failed session read.
  • Detail. Opening a row shows everything recorded about the event and its payload, including whatever the row cut off. A Maple event's id links to the same entry in the Settings audit log.
  • Filters for category, outcome and actor live in the URL. There is no platform filter or column yet: only Maple writes events today. Both come back with the second platform.
  • Paging. Load more continues from the last event shown. A failed page keeps the list and offers a retry. A next page is neither asked for nor accepted while other filters are loading.
  • A footnote says allowed reads, tool calls and SQL runs are in the Settings audit log, not here, so their absence does not read as "nobody read data".

Kit changes, each with the existing copies moved onto it:

  • tableGroupRow in the table kit (the releases list's day rows).
  • dayEyebrow, refusedBadge, expandChevron, jsonBlock and prettyJson in the page kit (the settings audit log).
  • nativeSelect takes a controlled value, so a filter change keeps keyboard focus instead of remounting the select.

The rail entry is visible to everyone who can open the Security hub. I did not add a separate rollout flag: the hub itself is still behind securityAudit.

Screenshots

State
Events events
An event opened detail
Phone width
Nothing recorded yet empty
Filters match nothing filtered
Not an admin member
Load failed error

The two existing pages that moved onto the shared kit pieces. Each pair of files is byte-identical.

Before After
Settings audit log before after
Releases list before after

Verified

  • Web typecheck passes. oxlint -c .oxlintrc.effect.json is clean on the changed files.
  • ui:index is current, ui:patterns:check passes, the UI kit baseline is unchanged (689).
  • 22 scene tests for the page and a unit test for the entry link. The security, route, settings, releases, lab and kit suites still pass.
  • Rendered from the lab (/lab/security?state=audit-log, -detail, -empty, -filtered-empty, -member, -error) in headless Chromium at 1440 and 390 wide.
  • Tested three times on the preview:web deploy against real rows by a separate agent; results and the trace numbers are in the comments below. The API answers the list in 136 ms at the median with one warehouse query per request.
  • One review pass over the page plus the review bot's notes; all fixed.

Not verified on the preview: a non-admin viewing the page (the only preview account is an admin). That state is covered by scene tests and the lab.

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

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

Pre-merge checks: Passed 1 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Title check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
Passed Passed checks: 1 passed

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
  • 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.

…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.
@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 preview-tinybird November 12, 2026 01:01 — with GitHub Actions Active
@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.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

🍁 Maple PR preview

Note

Preview resources were removed when the preview label came off.

Final commit 9f6d7fc · View workflow run

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

This comment has been minimized.

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.
… 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.
@Makisuo
Makisuo deployed to pr-preview November 12, 2026 13:07 — with GitHub Actions Active
@Makisuo
Makisuo deployed to preview-tinybird November 12, 2026 13:07 — with GitHub Actions Active
@Makisuo

Makisuo commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Retest on the preview (e150a50)

Seven of eight items passed. One did not, and I have corrected it rather than left it half-working.

Item Result
No "no matches" while the next filter loads (response delayed 3 s) Pass: loading rows, then 50 events
A scope refusal is a Permissions event with a POST /v2/dashboards style target and its reason Pass
Event id links to the same entry in the Settings audit log Pass
Pasting an alog_ id into the Settings lookup opens the entry Fail
Footnote opens the Data access tab Pass
Row times tick without a reload Pass
Selects are named Category and Outcome; a row toggle names the action and time Pass
Keyboard change of a select keeps focus on it across the URL change; Back restores the value Pass
Regression: new events appear, detail opens, Load more 50 to 68 Pass

Correction to my earlier comment. I wrote that the Settings lookup now opens an entry from its alog_ id. It did not: that page has no way to fetch an entry by id alone, so the lookup set the URL and opened nothing. 9f6d7fc removes that change and leaves the lookup as it was. The link from this 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 also finds entries that are no longer on the first page. That last part is covered by a unit test but has not been re-run on the preview.

Also since the retest: an actor typed but not yet applied survives a change of another filter (d3a9f40).

@maple-review-bot

This comment has been minimized.

@Makisuo
Makisuo deployed to pr-preview November 12, 2026 13:09 — with GitHub Actions Active
@Makisuo

Makisuo commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Final check on the preview (9f6d7fc)

  • The Event id link opens the Settings audit log on the minute ending at the entry, with the entry open and the same action and time, for the oldest of 68 events (after Load more), the newest, and a Denied one.
  • An actor typed but not applied stays in the field when Category changes. Enter applies it to the URL. Back clears it again.
  • No console or page errors.

That closes the item I had listed as unit tested only. Still not verified on the preview: a non-admin viewing the page, because the only preview account is an admin.

@Makisuo Makisuo removed 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 14:50 — with GitHub Actions Active
@Makisuo
Makisuo deployed to preview-tinybird November 12, 2026 14:50 — with GitHub Actions Active
The audit log is filtered by a member's email in the actor query parameter,
and the tracer wrote it verbatim into url.full and url.query. Its value is now
redacted on the server span, next to Effect's default list.

A failed copy to the audit trail from the direct write path is logged as
that, not as a failed audit log write: the entry itself was stored.
@maple-review-bot

This comment has been minimized.

# Conflicts:
#	packages/views/src/ui/INDEX.md
Main added the cost source and its provider key as audited resources. A key is a
credential, connecting a source is configuration.
@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.

@maple-review-bot

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

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge. No defects found; the paging and access state machines are pinned by 22 scene tests, and the kit extractions leave the existing pages rendering identically.
no findings · tests covered · risk medium · 1/1 new units observable

Adds the admin-only /security/audit-log page: events grouped by day, filters in the URL, per-event detail and cursor paging, with kit pieces extracted from the Settings audit log. Safe to merge. The listed McpQueryError issues come from the apps/ai MCP registry; this diff does not touch them.

  • /security/audit-log lists audit events by day, admin-only through audit_log:read.
  • Filters live in the URL; routeChanged re-reads the first page from them.
  • tableGroupRow, dayEyebrow, refusedBadge, jsonBlock, auditEntryHref move into shared kits.
  • nativeSelect accepts a controlled value so a filter change keeps focus.

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.

73fd519 · 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/table to main November 13, 2026 13:28
@Makisuo
Makisuo merged commit 392e2e8 into main Nov 13, 2026
28 of 40 checks passed
@Makisuo
Makisuo deleted the audit-trail/page branch November 13, 2026 13:29
@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-ai (production) at 2026-11-13 13:39 UTC as 392e2e8. 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
Operation Calls/h Errors p95
maple 137 → 136 2.2% → 1.5% 60149 → 60145 ms
SecurityApiAuditLogFirstPage 0 → 0 0.0% → 0.0% 0 → 0 ms

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