From 3ec2f3bf85244d3ae85d49e8358bb3bdf14efd38 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 14 May 2026 07:38:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20audit=20log=20=E2=80=94=20schem?= =?UTF-8?q?a,=20helper,=20admin=20UI,=20hooks=20for=20login/pair/firmware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service-admin-http/routes-admin.ts | 24 +++++- .../plugins/service-admin-http/routes-auth.ts | 14 ++++ .../service-admin-http/routes-firmware.ts | 17 ++++- server/src/plugins/service-store/mappers.ts | 19 +++++ .../src/plugins/service-store/migrations.ts | 21 ++++++ .../src/plugins/service-store/repository.ts | 59 +++++++++++++++ server/src/shared/audit.ts | 75 +++++++++++++++++++ server/src/shared/types.ts | 17 +++++ server/src/web-templates/admin-pages.tsx | 64 ++++++++++++++++ server/src/web-templates/layout.tsx | 1 + 10 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 server/src/shared/audit.ts diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index ee24954..fae9016 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -12,6 +12,7 @@ import { CameraNewPage, CameraEditPage, CameraDiscoverPage, + AuditLogPage, CameraDiscoverResultsPage, EntitiesPage, EntityNewPage, @@ -37,6 +38,7 @@ import { discover as onvifDiscover } from "../../shared/onvif.js"; import { generateBundle } from "../../shared/bundle.js"; import { captureSnapshot } from "../../shared/snapshot.js"; import { stripSecrets } from "../../shared/strip-secrets.js"; +import { audit } from "../../shared/audit.js"; interface DiscoverAddStream { profile_name: string; @@ -269,6 +271,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 301, headers: { location: "/admin/" } }); }); + // ---- Audit log ------------------------------------------------------------ + + app.get("/admin/audit", (event) => { + const user = event.context.user!; + const url = new URL(event.req.url); + const filterAction = url.searchParams.get("action")?.trim() || undefined; + const filterActorType = url.searchParams.get("actor_type")?.trim() || undefined; + const entries = deps.repo.listAudit({ + limit: 300, + action_prefix: filterAction, + actor_type: filterActorType as any || undefined, + }); + return htmlPage(AuditLogPage({ user: user.username, entries, filterAction, filterActorType })); + }); + // ---- System Health -------------------------------------------------------- app.get("/admin/health", (event) => { @@ -621,12 +638,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; try { - await confirmPairing(deps.repo, deps.auth, deps.secrets, { + const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, { code, nameOverride, initialLabels, replaceKioskId, }); + audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { + resource_type: "kiosk", + resource_id: result.kioskId, + metadata: { name: result.kioskName, code, replaced: !!replaceKioskId }, + }); } catch (err) { const user = event.context.user!; const kiosks = deps.repo.listKiosks(); diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts index 91a6ac5..0e8bc93 100644 --- a/server/src/plugins/service-admin-http/routes-auth.ts +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -5,6 +5,7 @@ import { type H3, readBody, getCookie, getQuery, getRequestHeader } from "h3"; import { htmlPage, redirectWithCookie, redirectClearCookie } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js"; +import { audit } from "../../shared/audit.js"; export function registerAuthRoutes(app: H3, deps: AdminDeps): void { @@ -45,6 +46,12 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString(); } deps.repo.updateUser(user.id, patch); + audit(deps.repo, event as any, "user.login", { + result: "failed", + actor_type: "system", + actor_label: username, + metadata: { failed_count: count, locked: count >= deps.auth.config.loginLockoutThreshold }, + }); return htmlPage(LoginPage({ error: "Invalid credentials.", username })); } @@ -54,6 +61,13 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void { last_login_at: new Date().toISOString(), }); + audit(deps.repo, event as any, "user.login", { + actor_type: "user", + actor_id: user.id, + actor_label: user.username, + metadata: { totp_pending: user.totp_enabled }, + }); + const totpPending = user.totp_enabled; const { cookieValue } = await deps.auth.createSession({ user, diff --git a/server/src/plugins/service-admin-http/routes-firmware.ts b/server/src/plugins/service-admin-http/routes-firmware.ts index 4f38fd5..76fd7ba 100644 --- a/server/src/plugins/service-admin-http/routes-firmware.ts +++ b/server/src/plugins/service-admin-http/routes-firmware.ts @@ -19,6 +19,7 @@ import { KioskFirmwarePanel, } from "../../web-templates/admin-pages.js"; import { getCoordinator } from "../../shared/coordinator-registry.js"; +import { audit } from "../../shared/audit.js"; import type { FirmwareChannel } from "../../shared/types.js"; const ALLOWED_CHANNELS: ReadonlySet = new Set(["stable", "beta", "dev"]); @@ -68,7 +69,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { const { sha256, signature } = deps.firmware.signBlob(buf); const artifactPath = await deps.firmware.storeBlob(buf, sha256); - deps.repo.createFirmwareRelease({ + const release = deps.repo.createFirmwareRelease({ id: randomUUID(), version, channel: channelRaw as FirmwareChannel, @@ -80,6 +81,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { release_notes: releaseNotes, uploaded_by: user.id, }); + audit(deps.repo, event as any, "firmware.upload", { + resource_type: "firmware_release", + resource_id: release.id, + metadata: { version, channel: channelRaw, arch, sha256, size: buf.length }, + }); return new Response(null, { status: 302, headers: { location: "/admin/firmware" } }); }); @@ -136,6 +142,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/firmware/:id/yank", (event) => { const id = String(getRouterParam(event, "id")); deps.repo.yankFirmwareRelease(id); + audit(deps.repo, event as any, "firmware.yank", { + resource_type: "firmware_release", + resource_id: id, + }); return new Response(null, { status: 302, headers: { location: "/admin/firmware" } }); }); @@ -207,6 +217,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { created_by: user.id ?? null, }); deps.repo.updateFirmwareRolloutState(rollout.id, "active"); + audit(deps.repo, event as any, "firmware.rollout.create", { + resource_type: "firmware_rollout", + resource_id: rollout.id, + metadata: { release_id: releaseId, percentage, target_count: targets.length }, + }); // Bump every targeted kiosk to check now (best-effort over WS). const coord = getCoordinator(); if (targets.length === 0) { diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 61f4639..babdc34 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -8,6 +8,9 @@ import type { ApiKey, ApiKeyScope, + AuditActorType, + AuditEntry, + AuditResult, Camera, CameraStream, CameraType, @@ -266,6 +269,22 @@ export function rowToKiosk(r: Row): Kiosk { }; } +export function rowToAuditEntry(r: Row): AuditEntry { + return { + id: n(r["id"]), + ts: s(r["ts"]), + actor_type: s(r["actor_type"]) as AuditActorType, + actor_id: nn(r["actor_id"]), + actor_label: sn(r["actor_label"]), + action: s(r["action"]), + resource_type: sn(r["resource_type"]), + resource_id: sn(r["resource_id"]), + ip: sn(r["ip"]), + metadata: j>(r["metadata"], {}), + result: s(r["result"]) as AuditResult, + }; +} + export function rowToFirmwareRelease(r: Row): FirmwareRelease { return { id: s(r["id"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index e87ecab..3c74ba2 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -760,4 +760,25 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER"); addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT"); }, + + // ---- Audit log ----------------------------------------------------------- + // Append-only record of security-relevant actions: logins, API key use, + // kiosk pair/replace, firmware upload/yank/rollout, admin CRUD of any + // resource. Read-only via admin UI, filterable. + `CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')), + actor_id INTEGER, + actor_label TEXT, + action TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + ip TEXT, + metadata TEXT NOT NULL DEFAULT '{}', + result TEXT NOT NULL DEFAULT 'ok' CHECK(result IN ('ok', 'failed')) + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 2078620..f96a0b2 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -14,6 +14,9 @@ import { randomBytes } from "node:crypto"; import type { ApiKey, ApiKeyScope, + AuditActorType, + AuditEntry, + AuditResult, Camera, CameraStream, CameraType, @@ -47,6 +50,7 @@ import type { } from "../../shared/types.js"; import { rowToApiKey, + rowToAuditEntry, rowToCamera, rowToCameraStream, rowToDisplay, @@ -1070,6 +1074,61 @@ export class Repository { ); } + // =========================================================================== + // audit_log + // =========================================================================== + + insertAudit(input: { + actor_type: AuditActorType; + actor_id: number | null; + actor_label: string | null; + action: string; + resource_type: string | null; + resource_id: string | null; + ip: string | null; + metadata: Record; + result: AuditResult; + }): void { + this.prep( + `INSERT INTO audit_log + (actor_type, actor_id, actor_label, action, resource_type, + resource_id, ip, metadata, result) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.actor_type, + input.actor_id, + input.actor_label, + input.action, + input.resource_type, + input.resource_id, + input.ip, + J(input.metadata), + input.result, + ); + } + + listAudit(opts: { + limit?: number; + actor_type?: AuditActorType; + action_prefix?: string; + } = {}): AuditEntry[] { + const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000); + const where: string[] = []; + const args: unknown[] = []; + if (opts.actor_type) { + where.push("actor_type = ?"); + args.push(opts.actor_type); + } + if (opts.action_prefix) { + where.push("action LIKE ?"); + args.push(`${opts.action_prefix}%`); + } + const sql = `SELECT * FROM audit_log ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY ts DESC LIMIT ?`; + args.push(limit); + const rs = this.db.prepare(sql).all(...(args as any[])); + return rs.map((r) => rowToAuditEntry(r as Record)); + } + // =========================================================================== // firmware_releases + firmware_rollouts // =========================================================================== diff --git a/server/src/shared/audit.ts b/server/src/shared/audit.ts new file mode 100644 index 0000000..4b9a6aa --- /dev/null +++ b/server/src/shared/audit.ts @@ -0,0 +1,75 @@ +/** + * Audit logging helper — single inline call from route handlers. + * + * audit(repo, event, "user.login", { result: "ok" }); + * audit(repo, event, "firmware.upload", { resource_id: rel.id, metadata: { version, channel } }); + * + * Pulls actor + ip out of the h3 event context. Never throws — logging + * failure must not break the caller's request. + */ +import type { Repository } from "../plugins/service-store/repository.js"; +import type { AuditActorType, AuditResult } from "./types.js"; + +interface AuditCtx { + context?: { + user?: { id?: number; username?: string }; + apiKeyPrefix?: string; + session?: unknown; + }; + req?: { headers: { get(name: string): string | null } }; +} + +export interface AuditInput { + resource_type?: string; + resource_id?: string | number; + metadata?: Record; + result?: AuditResult; + /** Override actor (e.g. when system performs action on behalf of nobody). */ + actor_type?: AuditActorType; + actor_id?: number | null; + actor_label?: string | null; +} + +export function audit( + repo: Repository, + event: AuditCtx | null, + action: string, + input: AuditInput = {}, +): void { + try { + const ctx = event?.context; + let actor_type: AuditActorType = input.actor_type ?? "system"; + let actor_id: number | null = input.actor_id ?? null; + let actor_label: string | null = input.actor_label ?? null; + + if (!input.actor_type && ctx) { + if (ctx.apiKeyPrefix) { + actor_type = "api_key"; + actor_label = ctx.apiKeyPrefix; + } else if (ctx.user) { + actor_type = "user"; + actor_id = ctx.user.id ?? null; + actor_label = ctx.user.username ?? null; + } + } + + const headers = event?.req?.headers; + const ip = headers?.get("x-real-ip") + ?? headers?.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? null; + + repo.insertAudit({ + actor_type, + actor_id, + actor_label, + action, + resource_type: input.resource_type ?? null, + resource_id: input.resource_id != null ? String(input.resource_id) : null, + ip, + metadata: input.metadata ?? {}, + result: input.result ?? "ok", + }); + } catch { + /* never throw from audit */ + } +} diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 6094c7d..a5c0a28 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -222,6 +222,23 @@ export interface Kiosk { created_at: string; } +export type AuditActorType = "user" | "api_key" | "system" | "kiosk"; +export type AuditResult = "ok" | "failed"; + +export interface AuditEntry { + id: number; + ts: string; + actor_type: AuditActorType; + actor_id: number | null; + actor_label: string | null; + action: string; + resource_type: string | null; + resource_id: string | null; + ip: string | null; + metadata: Record; + result: AuditResult; +} + export type FirmwareChannel = "stable" | "beta" | "dev"; export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete"; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 1d0f602..ba8a7d2 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -4,6 +4,7 @@ import { js } from "jsx-htmx"; import { Layout } from "./layout.js"; import type { + AuditEntry, Camera, Display, Entity, @@ -2981,3 +2982,66 @@ export function FirmwareRolloutsPage(props: FirmwareRolloutsPageProps) { ); } + +// ---- Audit log ------------------------------------------------------------- + +interface AuditLogPageProps { + user: string; + entries: AuditEntry[]; + filterAction?: string; + filterActorType?: string; +} + +export function AuditLogPage(props: AuditLogPageProps) { + return ( + +

+ Append-only record of admin + kiosk + system actions. Most recent first. +

+
+ + + +
+
+ + + + + + + + {props.entries.length === 0 ? ( + + ) : ( + props.entries.map((e) => ( + + + + + + + + + + )) + )} + +
TimeActorActionResourceIPResultMetadata
No entries.
{formatTime(e.ts)} + {e.actor_type} + {e.actor_label && {e.actor_label}} + {e.action}{e.resource_type ? `${e.resource_type}#${e.resource_id ?? ""}` : ""}{e.ip ?? ""} + {e.result} + + {Object.keys(e.metadata).length === 0 ? "" : JSON.stringify(e.metadata)} +
+
+
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 186c23a..997ec1f 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) { +