Repository navigation
feat(security): the audit log page in the Security hub - #2520
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.
|
Caution Review failedThe pull request is closed. Pre-merge checks:
|
This comment has been minimized.
This comment has been minimized.
…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.
This comment has been minimized.
This comment has been minimized.
🍁 Maple PR previewNote Preview resources were removed when the preview label came off. Final commit |
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.
This comment has been minimized.
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.
Retest on the preview (
|
| 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).
This comment has been minimized.
This comment has been minimized.
Final check on the preview (
|
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.
This comment has been minimized.
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.
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.
# Conflicts: # packages/domain/src/http/index.ts
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. Adds the admin-only
Production impactOpen errors in the changed files (2)
After this merges, Maple checks whether they stop.
|
Maple: after this shipped✅ Shipped clean. Nothing this change touches got worse in the hour after it deployed. Deployed to Errors in the changed files:
Maple looks once, an hour after the first deploy that carries the merge. |
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.audit_log:readit 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.Kit changes, each with the existing copies moved onto it:
tableGroupRowin the table kit (the releases list's day rows).dayEyebrow,refusedBadge,expandChevron,jsonBlockandprettyJsonin the page kit (the settings audit log).nativeSelecttakes a controlledvalue, 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
The two existing pages that moved onto the shared kit pieces. Each pair of files is byte-identical.
Verified
oxlint -c .oxlintrc.effect.jsonis clean on the changed files.ui:indexis current,ui:patterns:checkpasses, the UI kit baseline is unchanged (689)./lab/security?state=audit-log,-detail,-empty,-filtered-empty,-member,-error) in headless Chromium at 1440 and 390 wide.preview:webdeploy 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.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.