Skip to content

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

Open
Makisuo wants to merge 11 commits into
audit-trail/tablefrom
audit-trail/page
Open

Makisuo wants to merge 11 commits into
audit-trail/tablefrom
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.

/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

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

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: a54a961d-bb97-412d-a41b-9383a766ff2c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
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.

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

This comment has been minimized.

@Makisuo

Makisuo commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Tested on the preview, with traces

A separate agent signed in to app-pr-2520.maple.dev as the shared reviewer, created, renamed and deleted test objects, and checked the page against what it had done. This ran on 001b331, before the last fix commit.

real events on the preview

Passed

  • Each change showed up with the right actor, action, category, target and time. Recorded trails Occurred by 3 to 6 seconds; events were on the page 6 to 26 seconds after the action.
  • Detail fields, open and close, every filter, Clear filters, Back and Forward, Load more (50 to 57, unique, sorted, button gone at the end), the 390 px layout.
  • Viewing pages added no events: 11 before and after 8 page views and a keyed read.
  • Two scope refusals from a dashboards:read key each produced a Denied row with its reason.
  • No console errors. One request to the events endpoint per page load, always 200.

Found and fixed since (736658e on #2510, e150a50 here)

  • An empty result stayed on screen as "no matches" while the next filter loaded. It now shows loading rows.
  • A scope refusal was filed under Authentication with an empty target. It is now a Permissions event whose target is the method and path. The screenshot above predates this.
  • Pasting an event id into the Settings lookup found nothing. The lookup now opens an entry from its alog_ id, and the id in this page's detail links there.
  • The footnote opened the Changes tab. It now opens Data access.
  • Row times did not tick. The clock is read every minute now.
  • The filter selects were named after their "All" option for screen readers.

Not fixed here, existing behavior

  • A refusal by the session-only tier (an API key calling /internal/...) and a request with an invalid key are not audited at all, so they cannot appear. The second has no org to record under.
  • Clearing the actor field by keyboard drops focus to the page. That is the kit's search input and affects every page that uses it.
  • Renaming in the UI was not possible on this preview: preview:web has no live sync, so editing is disabled. Those changes were made through the API with the session.

Not tested: a non-admin viewing the page (the only account is an admin; a key gets 403 and no session gets 401 from the endpoint), a "Yesterday" heading, and arrow keys on a closed native select in headless Chromium.

Traces (deployment.environment = pr-2520, last 12 hours, no errors on any span below)

Span Count p50 p95 max
browser GET of the events endpoint 65 315 ms 2061 ms
web worker GET /_api/* 66 156 ms 1430 ms
maple-api GET /internal/audit-trail/events 72 136 ms 394 ms
AuditTrailService.list 69 48 ms 105 ms 148 ms
OrgMembersService.listMembers 55 94 ms 186 ms 269 ms
AuditTrailService.write (consumer, off the response path) 11 296 ms 330 ms 333 ms
AuditEvents.processBatch 47 317 ms 663 ms 694 ms
  • One warehouse query per list request. No N+1.
  • The API answers in 136 ms at the median. Most of that is the member directory read that names Maple's own users (94 ms), not the warehouse (48 ms). It could be cached if this page gets heavy use.
  • The slow tail is between the browser and the API, not in this code: the same 1.4 to 2.6 second p95 shows on every other request the shell makes on this preview (LoadInfraSurfaces, LoadCustomer), which points at cold preview workers.
  • Slowest list traces: d6c0e5eb417298ef1d448aef13fc5f1e (148 ms), 7ad1ffeb979b811a9e32145ee2082459 (144 ms).

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

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

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge. The admin gate, draft-carry-over and stale-cursor guards are each pinned by a scene test, and the moved kit pieces match the markup they replace.
no findings · tests covered · risk medium

Adds the admin-only /security/audit-log page to the Security hub (URL filters, cursor paging, per-row detail) and moves five shared kit pieces onto it. Safe to merge. The McpQueryError issues mapped to page/registry.ts are MCP query failures from already-closed Postgres scopes; this diff only adds an import there and does not fix them.

  • /security/audit-log lists audit events by day, gated on audit_log:read
  • Filters and paging live in the URL; a typed actor survives a filter change
  • tableGroupRow, dayEyebrow, refusedBadge, expandChevron, jsonBlock extracted into the kits
  • nativeSelect takes a controlled value without changing uncontrolled callers

Fixed since the last review

  • ✅ F5 · Changing a select wipes the actor text typed but not yet submitted

Production impact

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

After this merges, Maple checks whether they stop.

9f6d7fc · 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 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

This branch was successfully deployed

2 active deployments
pr-preview — 9f6d7fc9 Deployed Nov 12, 2026 by Makisuo via deploy-pr-preview #11652
preview-tinybird — 9f6d7fc9 Deployed Nov 12, 2026 by Makisuo via branch #1221
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