mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat(server): audit log — schema, helper, admin UI, hooks for login/pair/firmware
This commit is contained in:
parent
d1fd128ea0
commit
3ec2f3bf85
10 changed files with 309 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
75
server/src/shared/audit.ts
Normal file
75
server/src/shared/audit.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||
<hr />
|
||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue