think
16px
820px

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/actorsActorRow per (engine, surface, user_id): calls, error_calls, tokens_in/out, off_hours_calls, operations/models (distinct counts, not lists — confirmed int64 in the Go DTO), first_seen/last_seen. Served ORDER BY tokens_out DESC (confirmed in store/actors.go), noted on the response type.
  • getSecurityWarnings()GET /api/security-warnings — reuses the existing EarlyWarning interface (the server reuses predict.Warning verbatim) + 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 InlineNotification per warning; severityToKind reused from lib/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-allclear note (success-accented, role="status"): "No security warnings in the last 24 h." + "Evaluated window: (WIB)". 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).
  • 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 andEGRESS_FLOOR_TOKENS (50_000); undefined otherwise. Constants mirror secEgressOutlierX/secEgressFloor in internal/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) (mirrors secOffHours); undefined below — deliberately not 'good' (an untinted figure means "nothing suspicious", not a positive health reading).
  • unattributedShare(actors){unattributedCalls, totalCalls, share}, undefined on 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__gate state (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 is ApiError.status === 403; the query's retry skips 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

  1. 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.
  2. Actors seen — count of rollup rows, "distinct engine/surface/user identities in the range".
  3. Unattributed shareformatPercent(share); subtitle "N of M calls carry no user — identity gap, enforce X-User-Id"; + "No calls in the selected range" when the window is empty.
  4. 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_by regex 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.tsx Security link per role · api.test.ts +3.

Judgment calls

  1. operations/models typed 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".
  2. 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.
  3. 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/secMinCalls guards 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).
  4. 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.
  5. 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.
  6. Fixture surfaces avoid the literal string api in the view test: an actor triple like engine/api/user would trip the /api/ copy regex on pure data. The rule targets the view's own copy; the fixtures keep the assertion meaningful.