Security view — build report
Commit: 3280c9c feat(security): per-actor security view — warnings, actors table, watcher panel (master)
Gates: npx tsc --noEmit clean · npx vitest run 44 files / 400 tests, all green (was 373 — 27 added) · npm run build passes (pre-existing chunk-size warning only).
What was built
New /security route, nav label Security, minRole: 'operator' — between Calls and Audit. Executives see neither the nav item nor the route (redirected to Overview); operator and auditor see both. Asserted in nav.test.ts (explicit "executive never sees Security" test), App.test.tsx (executive redirect + operator/auditor reach), and app-shell.test.tsx (per-role link presence in the rendered header nav).
API client (src/lib/api.ts) — three new methods, shapes verified against ../ahu-ai-observatory/internal/api/security.go
getActors({from,to})→GET /api/actors—ActorRowper (engine, surface, user_id): calls, error_calls, tokens_in/out, off_hours_calls,operations/models(distinct counts, not lists — confirmedint64in the Go DTO), first_seen/last_seen. ServedORDER BY tokens_out DESC(confirmed instore/actors.go), noted on the response type.getSecurityWarnings()→GET /api/security-warnings— reuses the existingEarlyWarninginterface (the server reusespredict.Warningverbatim) +window_from/window_to. No range params — fixed 24h server-side lookback.getBodyAccessStats({from,to})→GET /api/body-access-stats— auditor-only;{actor (token-hash), role, fetches, last}.
api.test.ts: +3 tests (URLs, params encoding, 403 → typed ApiError).
Warning banners + explicit all-clear
- One
InlineNotificationper warning;severityToKindreused fromlib/predict/metrics(critical→error, warning→warning, info→info) — same mapping, zero duplication. Subtitle = detail + compact evidence tail (tokens_out 300,000 · peer_median 2,000), same pattern as PredictView. - Empty → an affirmative
.security-allclearnote (success-accented,role="status"): "No security warnings in the last 24 h." + "Evaluated window:– . Warnings-present state carries the same window caption under the banners. A failed evaluation renders an error notification — never silently read as all-clear (tested).(WIB)" - Own query key, polled every 60 s, range-independent (the filters row says so explicitly: "Warnings always cover the last 24 h; the range drives the tables below").
Actors table (centerpiece)
Carbon DataTable size="sm" in a ChartPanel, rows in served order (tokens_out desc — no client re-sort, caption says "largest egress first"). Columns: Actor (mono engine/surface/ + real user or a gray Carbon Tag "unattributed"; blank surface renders -, the server's own label convention), Calls, Errors, Error rate (ToneNumber via the shared errorRateTone), Tokens out (toned only past the egress rule), Off-hours calls (toned only at ≥20), Ops, Models, Last seen (WIB via formatCallTimestamp). Caption verbatim per the brief (finest-identity + unattributed-will-resolve note). Empty → "No actors in the selected range."; failed query → error surfaced + tiles Unavailable.
lib/security/metrics.ts (pure, 17 unit tests)
egressOutlierTone(actorTokensOut, allTokensOut)→'warning'only when ≥EGRESS_OUTLIER_X (5)× median of the table's tokens_out and ≥EGRESS_FLOOR_TOKENS (50_000);undefinedotherwise. Constants mirrorsecEgressOutlierX/secEgressFloorininternal/predict/security.go. Boundary tests at exactly 5× and exactly 50k; lone-actor and under-floor cases proven non-firing.offHoursTone(n)→'warning'at ≥OFF_HOURS_WARNING_CALLS (20)(mirrorssecOffHours);undefinedbelow — deliberately not'good'(an untinted figure means "nothing suspicious", not a positive health reading).unattributedShare(actors)→{unattributedCalls, totalCalls, share},undefinedon zero calls (tile reads—, never a fabricated 0% that would read as "fully attributed").medianOf— non-mutating, even/odd cases tested.
Body access (watchers) panel
- Auditor: per-token rollup table — Actor (hash, mono — shares the access-log mono treatment), Role, Fetches, Last (WIB). Caption: actor = token hash, never a name or the raw token.
- Operator: the endpoint 403s → a designed
.security-watchers__gatestate (Locked icon, "Requires the auditor role" heading, one-paragraph why + "everything else on this page is unaffected"), rendered as the panel's content — NOT the generic "Couldn't load" error treatment and no raw server message (both asserted). Detection isApiError.status === 403; the query'sretryskips retries for 403 (a stable privilege boundary, not a transient failure) but keeps the default 3 for real failures. - Range-driven alongside the actors table; honest loading/error/empty states otherwise.
Tiles
- Active security warnings — count; tone good 0 / warning ≥1 / critical when any
severity==='critical'(same fold as PredictView's tile). Unavailable on error, never a reassuring 0. - Actors seen — count of rollup rows, "distinct engine/surface/user identities in the range".
- Unattributed share —
formatPercent(share); subtitle "NofMcalls carry no user — identity gap, enforce X-User-Id";—+ "No calls in the selected range" when the window is empty. - Off-hours calls — window total across actors, warning-toned at ≥20 via the same pure helper the table cells use.
Styles (carbon.scss, appended; Sass $spacing-* tokens only)
.security-allclear (success-accented all-clear, same construction as .recorded-notice), .security-window, .security-actor (+ tag alignment), .security-watchers__gate* (the designed 403 state), and .security__mono added to the existing access-log mono rule (one shared declaration).
Tests (27 added)
lib/security/metrics.test.ts— 17 (thresholds, boundaries, identity-gap arithmetic).views/SecurityView.test.tsx— 16: default fetches (range params on actors/body-access, none on warnings), banner severity mapping + evidence + WIB window, explicit all-clear + good-toned 0, critical tile tone, failed-evaluation honesty, served-order table + WIB + tone rules (egress/off-hours, toned AND untoned cases), unattributed tag, tiles arithmetic (84.0% share from 105/125), empty-range—, watcher table, the designed 403 state (generic error treatment asserted ABSENT), actors-failure honesty, and the/api/-group_byregex assertions in both the populated and the 403-degraded states.nav.test.ts+1 & updated role lists ·App.test.tsx+3 routing tests ·app-shell.test.tsxSecurity link per role ·api.test.ts+3.
Judgment calls
operations/modelstyped as counts, not lists. The brief's field list was ambiguous; the Go DTO (Operations int64,Models int64) settles it — rendered as plain counts under "Ops"/"Models".- Tone helpers return
undefined, not'good', below threshold. A behavioral rule not firing isn't a positive health reading; only the error-rate column (a real measured rate with shared fleet-wide thresholds) gets the full good/warning/critical treatment. - Egress median is over ALL rendered rows (actor included), no min-peers/min-calls guard client-side. The brief pinned the helper's rule to "≥5× the table median AND ≥50k"; the server's extra
secMinPeers/secMinCallsguards apply to banner firing, which remains exclusively the server's call — the cell tint is a reading aid over what's on screen, and the 50k floor already kills small-table false alarms (a lone actor can also never out-median itself). - 403 retry suppression on the watcher query only. Default react-query retries (3, backoff) would delay the designed degraded state ~7 s for every operator page-load for an outcome that cannot change mid-session; other errors keep the default retries.
- Warnings-fetch failure gets its own error banner (not just an unavailable tile): on a security surface, "couldn't evaluate" must be visually distinct from "evaluated: quiet", or a dead endpoint reads as an all-clear.
- Fixture surfaces avoid the literal string
apiin the view test: an actor triple likeengine/api/userwould trip the/api/copy regex on pure data. The rule targets the view's own copy; the fixtures keep the assertion meaningful.