Files
vantage/web/lib/auditEvents.ts
T
mrhid6 675689a458
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 8m2s
feat(audit): server-side paging, search and category filter; one event format
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
2026-08-10 15:25:48 +01:00

136 lines
5.9 KiB
TypeScript

/*
* Audit event types, presented.
*
* The page used to hold a map of eleven event types to labels and a second map
* of seven to colours. The server emits forty-seven. Everything unmapped fell
* through to the raw string, so one row read "Key Assigned" in green and the
* next "workflow.schedule_updated" in grey — the same kind of fact in two
* different formats, which made the column look like it carried a meaning it
* did not.
*
* So this derives rather than enumerates. Event types are named
* `<category>.<action>` consistently by every call site, which is a convention
* worth leaning on: the category names the subsystem, the action is
* humanised, and the tone comes from the verb. A type added to the server
* tomorrow gets a sensible label and colour here today, with no second list to
* remember. OVERRIDES exists only for the handful the rule reads badly for.
*/
export type AuditTone = "danger" | "warning" | "success" | "neutral";
/*
* The categories, in the order the sidebar presents their subsystems. Names
* are what the operator calls them, so `secretgroup` and `secret` collapse to
* one entry and `auth_provider` is "Single sign-on" rather than its identifier.
*/
export const AUDIT_CATEGORIES: { value: string; label: string }[] = [
{ value: "server", label: "Servers" },
{ value: "key", label: "SSH keys" },
{ value: "workflow", label: "Workflows" },
{ value: "monitor", label: "Monitors" },
{ value: "secret", label: "Secrets" },
{ value: "secretgroup", label: "Secret groups" },
{ value: "secrets", label: "Secrets access" },
{ value: "vuln", label: "Vulnerabilities" },
{ value: "workload", label: "Workloads" },
{ value: "console", label: "Console" },
{ value: "agent", label: "Agents" },
{ value: "updates", label: "OS updates" },
{ value: "auth_provider", label: "Single sign-on" },
{ value: "settings", label: "Settings" },
{ value: "license", label: "Licence" },
{ value: "instance", label: "Instance" },
];
const CATEGORY_LABELS = new Map(AUDIT_CATEGORIES.map((c) => [c.value, c.label]));
/*
* Tone is taken from the action verb, not the category: deleting a key and
* deleting a workflow are the same weight of act.
*
* Stems match with or without their past tense, because the two spellings both
* occur — `auth_provider.delete` beside `key.deleted`, `workload.stop` beside
* `workflow.schedule_disabled`. Matching only the past tense left half the
* destructive events drawn in the same grey as a settings change.
*
* `accepted` is anchored because `unaccepted` contains it: unanchored, the
* negation matched its own root and withdrawing an acceptance was drawn as the
* same caution as granting one.
*/
const TONE_RULES: [RegExp, AuditTone][] = [
[/(delete|revoke|fail|offline|reap|cancel|destroy|remove)/, "danger"],
// Accepting a finding is a decision to live with a known risk, so it reads
// as a caution rather than an achievement. Withdrawing one falls through to
// neutral: it puts the finding back where it started.
[/(skip|disable|expire|stop|(^|_)accepted)/, "warning"],
[/(create|upload|assign|appl|open|enable|import|start|restart|sync)/, "success"],
];
/*
* Only where the derived text is wrong or reads clumsily. Anything absent is
* derived, which is the point — this list should stay short.
*/
const OVERRIDES: Record<string, string> = {
"key.generation_dispatched": "Key generation requested",
// These three arrive as `"workload." + action`, so the action really is a
// bare imperative rather than a name anyone chose.
"workload.start": "Workload started",
"workload.stop": "Workload stopped",
"workload.restart": "Workload restarted",
// The provider events are named in the imperative where every other
// subsystem uses the past tense; say what happened, like the rest.
"auth_provider.create": "Provider added",
"auth_provider.update": "Provider updated",
"auth_provider.delete": "Provider removed",
"agent.update_dispatched": "Agent update sent",
"updates.applied": "OS updates applied",
"secrets.token_rotated": "Read token rotated",
"secret.revealed": "Secret revealed",
"secretgroup.deleted": "Secret group deleted",
"workflow.defaults_synced": "Default steps synced",
"workflow.run_triggered": "Run started",
"workflow.run_cancelled": "Run cancelled",
"workflow.scheduled_run": "Scheduled run started",
"workflow.schedule_skipped": "Scheduled run skipped",
"auth_provider.ack_notice": "Callback change acknowledged",
"console.proxy_failed": "Console relay failed",
"console.proxy_opened": "Console relay opened",
"instance.reaped": "Instance deleted",
"vuln.rescan": "Rescan requested",
"workload.logs_read": "Workload logs read",
};
export interface AuditEventDisplay {
/** The subsystem, for the chip: "Workflows". */
category: string;
/** What happened, sentence case: "Schedule updated". */
action: string;
tone: AuditTone;
}
export function describeAuditEvent(eventType: string): AuditEventDisplay {
const dot = eventType.indexOf(".");
const prefix = dot === -1 ? "" : eventType.slice(0, dot);
const rest = dot === -1 ? eventType : eventType.slice(dot + 1);
const tone = TONE_RULES.find(([re]) => re.test(rest))?.[1] ?? "neutral";
const override = OVERRIDES[eventType];
const action = override ?? sentenceCase(rest);
return {
// An unknown prefix is shown as itself rather than hidden: a category
// this file has not been taught about is still better named by the
// server's own word for it than by nothing.
category: CATEGORY_LABELS.get(prefix) ?? sentenceCase(prefix || "Event"),
action,
tone,
};
}
function sentenceCase(s: string): string {
const words = s.replace(/[._]/g, " ").trim();
if (!words) return "";
return words.charAt(0).toUpperCase() + words.slice(1);
}