feat(audit): configurable per-action modes for allowed read decisions - #613
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n OOM Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… and at shutdown Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ument settings API Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes 14 items from the whole-branch review of audit decision modes: summary path truncation at 256 vs http_request_t's 512-byte path, implicit struct padding in the summary key breaking memcmp/hash portability, a redundant drain rebuild on an empty table, a missing mutex serializing concurrent settings PUTs, a silent failure path when summary metadata is missing, a misleading startup log that conflated two independent failure causes, plus new coverage for HEAD summarization, full summary detail fields (including a long path), settings-update audit details, and a combined retention+modes PUT. Frontend: prefill the audit history "since" filter to the last 24h when an unindexed event_type filter is chosen with no time bound, to avoid an expensive full-table detail scan. Docs updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous assert compared the struct size with the end of its last member, which always holds when that member sets the struct alignment. Compare with the sum of all member sizes instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Six unresolved findings remain, including two critical lifecycle and database-consistency issues.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds configurable record, summarize, and off modes for allowed authorization audit decisions, with buffered summaries and supporting API/UI updates.
Changes:
- Adds fixed-capacity, windowed audit summarization and lifecycle flushing.
- Extends settings, event filtering, UI, documentation, and localization.
- Adds unit/UI tests and sanitizer CI coverage.
File summaries
| File | Reviewed changes | Final review notes |
|---|---|---|
web/tests/auditHistory.spec.js |
Tests summary display and filtering helpers. | No final review comment. |
web/public/locales/en.json |
Adds audit interface strings. | No final review comment. |
web/js/components/preact/users/AuditHistoryModal.jsx |
Integrates summary rows and event-type filtering. | No final review comment. |
web/js/components/preact/users/auditHistory.js |
Provides audit history formatting and mode helpers. | No final review comment. |
web/js/components/preact/users/AuditDecisionModes.jsx |
Adds per-action mode controls. | No final review comment. |
tests/unit/test_audit_summary.c |
Tests summary accumulation and draining. | No final review comment. |
tests/unit/test_audit_log.c |
Tests modes, persistence, flushing, and filtering. | No final review comment. |
tests/unit/CMakeLists.txt |
Registers audit tests. | No final review comment. |
src/web/audit_summary.c |
Implements the fixed-capacity summary accumulator. | No final review comment. |
src/web/audit_log.c |
Applies modes and persists summaries. | Critical (1 vote, line 164): database switches can leave modes and pending summaries associated with the old database. Moderate (1 vote, line 264): mode updates are not synchronized with summary insertion. Moderate (1 vote, line 253): draining can race with producers and duplicate rows. Moderate (1 vote, line 235): serialization failures can produce empty details instead of dropping the summary. |
src/web/api_handlers_audit.c |
Implements audit settings and event filtering APIs. | No final review comment. |
src/database/db_audit.c |
Persists modes and filters event types. | No final review comment. |
src/core/main.c |
Loads and flushes summaries during application lifecycle. | Critical (1 vote, line 1707): shutdown flushing can race with in-flight request handlers. Moderate (2 votes, line 1366): backward wall-clock adjustments can indefinitely delay periodic flushing. |
include/web/audit_summary.h |
Declares summary interfaces. | No final review comment. |
include/web/audit_log.h |
Declares audit mode and flush interfaces. | No final review comment. |
include/database/db_audit.h |
Defines audit modes and query fields. | No final review comment. |
docs/API.md |
Documents audit settings and filtering APIs. | No final review comment. |
.github/workflows/sanitizer.yml |
Adds audit tests to sanitizer coverage. | No final review comment. |
Review details
Suppressed comments (3)
src/web/audit_log.c:267
- The flush and the per-action atomic stores are not synchronized with
audit_log_authorization(). A request can readsummarize, be preempted here, then add a summary entry after this flush while the new mode isofforrecord; that entry will later be persisted as a summary, so the mode change is not a reliable boundary. Synchronize the mode read plus summary insertion with the update, or add a generation/recheck scheme that handles entries created under the old mode.
audit_log_flush_summaries(false);
if (db_audit_save_decision_modes(modes) != 0) return -1;
for (int i = 0; i < AUTHZ_ACTION_COUNT; i++) {
atomic_store(&decision_modes[i], (int)modes[i]);
src/web/audit_log.c:255
audit_summary_drain()removes these entries before the database writes below and releases the summary mutex. A request that adds the same key while this batch is being written can therefore create a second entry for the same window; when fewer than 8 entries were drained, the loop exits and emits two rows instead of one aggregated row. Keep in-flight keys mergeable until their write completes, or otherwise coordinate producers with the drain.
size_t drained = audit_summary_drain(!closed_windows_only, current_window,
batch, AUDIT_SUMMARY_FLUSH_BATCH);
for (size_t i = 0; i < drained; i++) write_summary_row(&batch[i]);
src/web/audit_log.c:238
- The fallback still appends
{}when summary detail construction, redaction, or serialization failed. That creates anallowedaudit row with noevent_type,count, or time range, so the aggregated decisions are no longer identifiable or filterable; the documented failure behavior is to log and drop an unwritable summary. Treat a missing serialized summary as a failed write instead of inserting an empty-details row.
.details_json = serialized ? serialized : "{}",
.occurred_at = entry->first_at,
};
if (db_audit_append(&input, NULL) != 0) {
- Files reviewed: 18/18 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| int audit_log_decision_modes_init(void) { | ||
| audit_decision_mode_t modes[AUTHZ_ACTION_COUNT]; | ||
| int rc = db_audit_load_decision_modes(modes); | ||
| for (int i = 0; i < AUTHZ_ACTION_COUNT; i++) { | ||
| atomic_store(&decision_modes[i], (int)modes[i]); |
There was a problem hiding this comment.
Fixed in 44ff0a0: flush pending summaries before shutdown_database(), and call audit_log_decision_modes_init() (which reloads modes and reallocates a fresh summary table) once the live database is known, on both the success and the revert-to-old-path branches. This mirrors the ordering already used in both of main.c's shutdown paths. No unit test covers it — this handler (and DB-path switching generally) has no test harness in this codebase (tests/unit/test_api_handlers_settings.c doesn't exist), so I verified it by inspection and by reasoning from the same pattern main.c already uses correctly.
… switch Addresses two findings from Copilot's review of PR opensensor#613: - write_summary_row() fell through to inserting a details-less "{}" row when redaction/serialization failed (allocation failure only), instead of logging and dropping the write like every other failure path. The empty row still passed db_audit_append()'s validator, so it silently created an allowed audit_events row with no event_type, invisible to every filter that looks for authorization.summary. - handle_post_settings()'s runtime database-path switch shut down and reinitialized the database without touching audit state: in-memory decision modes kept belonging to the database that was just closed, and any summaries still buffered got flushed into the *new* database by the next window close, misattributed. Flush before the shutdown, and reload modes (which reallocates a fresh summary table) once the live database is known, mirroring the ordering already used in both of main.c's shutdown paths. test_audit_log (38) and test_audit_summary (8) still pass. No test harness exists for api_handlers_settings.c to cover the second fix against; DB-path switching has no unit tests at all in this codebase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed 44ff0a0 addressing two of Copilot's findings (replied inline on each thread):
Replied with rationale on the other three inline findings rather than changing code — the shutdown-race and backward-clock ones are both bounded/self-healing and match trade-offs the design already documents or patterns already used elsewhere in main.c's loop, so a special-case fix here felt like the wrong place for it. The two "suppressed" findings in the review body ( |
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate findings affect mode-update atomicity and summary correctness; the API documentation nit also remains.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
src/web/api_handlers_audit.c:440
- If
cJSON_CreateObject()fails for a changed action, thiscontinuecan leavechangesempty or incomplete. The handler then skipsaudit_log_set_decision_modes()(or persists only a subset) and can return 200 even though the validated request did not apply all requested modes. Abort with a 500 before persisting any mode changes, just as the surrounding code does when the changes array itself cannot be allocated.
src/web/audit_log.c:278 - The mode transition is not synchronized with summary accumulation: a request can read the old
summarizemode, block while this flush runs, and then add its entry after the flush (even after the newoffmode is published). That stale entry remains pending and can be written later, so a mode change is not a complete flush andoffcan still produce a summary after the update. Serialize the mode read/add operation with the transition or attach a generation and discard entries from the old mode.
docs/API.md:370
event_typeis documented here, but the authoritative optional-query-parameter table above (lines 288–295) still omits it. Add this filter to that table so API consumers do not have to discover a supported parameter only in the later performance note.
`GET /api/audit/events` and its CSV export also accept `event_type`, matching
`details.event_type` exactly (for example `authorization.summary`). This
src/web/audit_log.c:306
- These credential fields participate in byte-wise key equality, so the same user/action/target/client/window is split into separate rows when authentication changes or when the user uses different scoped tokens. This contradicts the documented summarize contract of one event per user/action/target/client and can materially increase the write volume; keep credential metadata as first-sample fields rather than key dimensions, or explicitly define the grouping as credential-level.
safe_strcpy(key.auth_method,
user && user->authentication_method[0] ? user->authentication_method : "unauthenticated",
sizeof(key.auth_method), 0);
if (user && user->authenticated_via_scoped_token) {
safe_strcpy(key.api_token_uuid, user->api_token_uuid, sizeof(key.api_token_uuid), 0);
- Files reviewed: 19/19 changed files
- Comments generated: 0 new
- Review effort level: Lite
Implements the direction from #604 / #605: let an administrator choose which authorization audit entries are required, and cut the volume of the noisy ones.
Problem
audit_log_authorization()writes a durableaudit_eventsrow for every authorization decision, allowed ones included. On the install from #604, two UI polling paths dominate:GET /api/detection/results/<stream>→live.viewallowedGET /api/system/logs→system.adminallowedPruning (#605) bounds the size, but the write rate is what defeats SQLite's online backup: it restarts from page one whenever another connection writes, and 47 consecutive scheduled backups failed.
Change
Each authorization action gets a mode for allowed decisions on
GET/HEADrequests:record(default)summarizeoffAlways recorded regardless of mode: denied and error decisions, any allowed decision on a non-
GET/HEADrequest, and everything written throughaudit_log_operation()/audit_log_append()(sign-ins, operation outcomes, retention and mode changes). "Changes state" is decided by HTTP method rather than the catalog'sdestructiveflag, becausesystem.admincovers both log-viewer polling (GET /api/system/logs) and restarts (POST /api/system/restart).Defaults reproduce current behavior exactly: no setting row, an unknown action or an invalid value in storage all mean
record, so no migration is needed and a downgrade simply ignores the key.Settings and API
system_settingsrow,audit_allowed_decision_modes, holding only non-default entries, e.g.{"live.view":"summarize"}. Loaded into an atomic per-action table at startup; the request path reads it without a lock or a query.GET /api/audit/settingsaddssummary_window_secondsandallowed_decision_modes(every catalog action, in catalog order, with category and description).PUT /api/audit/settingsacceptsretention_days,allowed_decision_modes, or both. The whole body is validated before anything is saved; an unknown action or invalid mode returns400and persists nothing. Changing modes flushes pending summaries, persists, swaps the in-memory table, and recordsaudit.settings.updatewith the previous and new mode of each changed action. PUTs are serialized.GET /api/audit/eventsand the CSV export acceptevent_type(matchesdetails.event_type).docs/API.mddocuments all of the above.Summaries
src/web/audit_summary.c); no allocation per request, its own mutex, never the global DB mutex. Keys have explicit padding plus a_Static_assertthat the struct has no implicit gaps, since hashing and equality are byte-wise.now - now % 900). Entries are flushed when their window closes (main loop, ~60 s), on a mode change, when the table is full, and at shutdown in both cleanup paths (afterhttp_server_stop(), beforeshutdown_database()and its final backup).audit_eventsrow: outcomeallowed,occurred_at= first seen,request_idof the first request, and details{"event_type":"authorization.summary","count":…,"first_at":…,"last_at":…,"window_seconds":900,"method":…,"path":…,"decision_source":…,"explanation":…},passed through the same redaction as
audit_log_append(). Retention, pruning, filtering, export and the history viewer work unchanged.SIGKILLloses up to one window of summary counts. Always-recorded events are never buffered.UI
Users → Security & Access Audit gains an Allowed decisions panel next to Retention (
AuditDecisionModes.jsx): actions grouped by category, a Record each / Summarize / Off selector per action, an inline warning for Off, and one Save button that sends only changed actions. Summary rows in the event list show a×Nbadge and the first-to-last time range, and the event-type filter gains Summaries / Individual decisions. Choosing an event type with no start date prefills the last 24 hours (see below). New strings are inen.jsononly.Known trade-offs (called out deliberately)
event_typefiltering scans event details. No index coversjson_extract(details_json,'$.event_type'). On a copy of a 790,741-row table: count 0.63 s + page 0.87 s under the DB mutex, versus 0.007 s with a 24 hsince. The UI therefore prefillssince, and the docs recommend a time range. An expression index would fix API callers too, but needs a migration — happy to add one if you want it; I didn't want to take a migration number unasked.POST /api/telemetry/playeris authorized aslive.viewper stream, so the non-GET rule records it (~26 rows/min with 7 cameras open). It doesn't change state, so it could reasonably be treated as a read or not produce authorization rows at all — left as a follow-up question rather than special-cased here.record, and the next PUT persists from that state. Saving retention in the UI resets unsaved mode edits.Testing
test_audit_summary(8): wall-clock window alignment, accumulation keeping the first sample, distinct clients as separate entries, full table rejecting new keys, draining closed windows while keeping the current one, output capacity, uninitialized table, and concurrent threads incrementing one key with an exact final count.test_audit_log(38 total, 23 new): mode persistence (defaults, round trip storing only non-defaults, invalid stored entries ignored, reload into memory); record/off/summarize behavior; distinct camera and client produce separate rows while a different path does not; allowed mutating requests, denials and errors never summarized;HEADsummarized likeGET; every summary detail field including a >256-char path; closed-window, mode-change and full-table flushes; backward clock step; failed write dropped without crashing; settings GET/PUT including atomic validation,audit.settings.updatedetails, no event for unchanged modes, and a combined retention+modes PUT;event_typefilter through both the DB and HTTP layers. The shutdown flush is exercised in production below rather than by a unit test (main()has no harness).event_typebind, the mode load loop, the path width).auditHistory.spec.jscovers grouping, changed-mode computation, summary detection/formatting and thesinceprefill.test_audit_logandtest_audit_summaryare added to the ASan+UBSan CI job'sctest -Rlist. Locally both pass under ASan+UBSan and under ThreadSanitizer with zero warnings (local-only evidence; CI has no TSan job). Local sanitizer builds used-DENABLE_LITERT=OFFbecause third-party XNNPACK does not compile under GCC 14.2 in those configurations — unrelated to this change.Production verification
Deployed on the #604 install (0.41.18 + these commits, replacing the local suppression patch I had been carrying), with
live.viewandsystem.adminset to Summarize:live.viewsummary rows covering 7,332 decisions (4–1,824 each) plus onesystem.adminrow.Flushed 7 audit summaries at shutdown, rows present withlast_atat the restart second; modes persisted.Designed and implemented with Claude Code (Claude Opus 5) as co-author — design spec, task-by-task implementation with per-task review, and a whole-branch review before this PR.
🤖 Generated with Claude Code