/* * 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 * `.` 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 = { "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); }