feat(server): audit log — schema, helper, admin UI, hooks for login/pair/firmware

This commit is contained in:
Mitchell R 2026-05-14 07:38:18 +02:00
parent d1fd128ea0
commit 3ec2f3bf85
10 changed files with 309 additions and 2 deletions

View file

@ -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();

View file

@ -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,

View file

@ -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<FirmwareChannel> = 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) {

View file

@ -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<Record<string, unknown>>(r["metadata"], {}),
result: s(r["result"]) as AuditResult,
};
}
export function rowToFirmwareRelease(r: Row): FirmwareRelease {
return {
id: s(r["id"]),

View file

@ -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)`,
];

View file

@ -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<string, unknown>;
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<string, unknown>));
}
// ===========================================================================
// firmware_releases + firmware_rollouts
// ===========================================================================

View file

@ -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<string, unknown>;
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 */
}
}

View file

@ -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<string, unknown>;
result: AuditResult;
}
export type FirmwareChannel = "stable" | "beta" | "dev";
export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete";

View file

@ -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) {
</Layout>
);
}
// ---- Audit log -------------------------------------------------------------
interface AuditLogPageProps {
user: string;
entries: AuditEntry[];
filterAction?: string;
filterActorType?: string;
}
export function AuditLogPage(props: AuditLogPageProps) {
return (
<Layout title="Audit log" user={props.user} activeNav="audit">
<p style="color:#666; margin-bottom:1rem">
Append-only record of admin + kiosk + system actions. Most recent first.
</p>
<form method="get" action="/admin/audit" style="display:flex; gap:0.5rem; margin-bottom:1rem">
<input type="text" name="action" placeholder="action prefix (e.g. firmware.)"
value={props.filterAction ?? ""} class="form-input" />
<select name="actor_type" class="form-input" style="max-width:200px">
<option value="" selected={!props.filterActorType}>any actor</option>
{(["user", "api_key", "kiosk", "system"] as const).map((t) => (
<option value={t} selected={props.filterActorType === t}>{t}</option>
))}
</select>
<button type="submit" class="btn">Filter</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>IP</th><th>Result</th><th>Metadata</th>
</tr>
</thead>
<tbody>
{props.entries.length === 0 ? (
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No entries.</td></tr>
) : (
props.entries.map((e) => (
<tr>
<td style="font-size:0.8rem; white-space:nowrap">{formatTime(e.ts)}</td>
<td style="font-size:0.85rem">
<span class="badge badge-gray">{e.actor_type}</span>
{e.actor_label && <span style="margin-left:0.25rem">{e.actor_label}</span>}
</td>
<td style="font-family:monospace; font-size:0.8rem">{e.action}</td>
<td style="font-size:0.85rem">{e.resource_type ? `${e.resource_type}#${e.resource_id ?? ""}` : ""}</td>
<td style="font-size:0.8rem">{e.ip ?? ""}</td>
<td>
<span class={`badge ${e.result === "ok" ? "badge-green" : "badge-red"}`}>{e.result}</span>
</td>
<td style="font-size:0.75rem; font-family:monospace; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap" title={JSON.stringify(e.metadata)}>
{Object.keys(e.metadata).length === 0 ? "" : JSON.stringify(e.metadata)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}

View file

@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />