Files
vantage-app/web/lib/auditEvents.ts
T
mrhid6 6ee203f5e9
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m52s
chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
2026-09-10 09:18:55 +00:00

141 lines
6.1 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: "token", label: "API tokens" },
{ 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",
"token.created": "API token created",
"token.revoked": "API token revoked",
"token.expired_use": "Expired API token used",
"settings.token_policy_updated": "API token policy updated",
};
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);
}