kaptein_viewmodel/audit.rs
1//! The single write-audit record: one format, two consumers.
2//!
3//! Used by both the local audit log and the incident-timeline export. Records
4//! **operations, not values** — secrets are never persisted here. Fully `serde`-
5//! serializable so it can cross the `serve`/gRPC-Web boundary and be exported.
6
7use serde::{Deserialize, Serialize};
8
9/// A reference to a Kubernetes resource.
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct ResourceRef {
12 pub group: String,
13 pub kind: String,
14 pub namespace: String,
15 pub name: String,
16}
17
18/// The operation that was performed. `McpToolCall` is intentionally **absent**: MCP is a
19/// *transport*, captured in `AuditEvent::source`, not a distinct operation. An agent that
20/// scales a deployment logs `Operation::Scale` with `source: Surface::Mcp`.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum Operation {
23 // Read operations (governance requires visibility into reads, not just writes).
24 List,
25 Describe,
26 Logs,
27 Diagnose,
28 // Write operations.
29 Delete,
30 Scale,
31 Restart,
32 Cordon,
33 Drain,
34 Evict,
35 /// Attach an ephemeral container to a running pod (`kaptein debug`). Distinct from
36 /// [`Operation::Exec`], which runs a command in an *existing* container — conflating
37 /// the two made the audit log's one `Exec` record impossible to attribute (finding V).
38 EphemeralAttach,
39 /// Run a command in an existing pod container (`kaptein exec`).
40 Exec,
41 PortForward,
42 /// A GitOps write path action (branch + PR), not an API-server write.
43 GitPrOpened,
44 /// An operator viewed (unmasked) a secret — the single most audit-relevant event for
45 /// a tool that masks secrets by default.
46 SecretViewed,
47}
48
49impl Operation {
50 /// Whether this operation mutates the cluster or opens a channel into a pod — the
51 /// set that must be **gated and audited** when performed through the CLI.
52 ///
53 /// The CLI's governance coverage test derives its assertion from this method rather
54 /// than hand-enumerating subcommands, which is how `exec` slipped past the gate
55 /// (finding U): a mutating operation that is never emitted by any subcommand is a
56 /// hole, and a subcommand that declares `--confirm` without `--break-glass` (or vice
57 /// versa) is a hole. Both are caught by reflecting over this set + the clap command
58 /// tree, so a new mutating operation fails CI until it is wired up.
59 pub fn is_governed(&self) -> bool {
60 !matches!(
61 self,
62 // Reads (visibility is audited via List/Describe/Logs/Diagnose, but they do
63 // not require a write gate).
64 Operation::List
65 | Operation::Describe
66 | Operation::Logs
67 | Operation::Diagnose
68 | Operation::SecretViewed
69 // Preview-only today: `drain` never evicts (no live write to gate).
70 | Operation::Drain
71 // A Git PR is a branch + review, not an API-server write.
72 | Operation::GitPrOpened
73 )
74 }
75}
76
77/// The outcome of a write attempt.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub enum Outcome {
80 Applied,
81 DryRun,
82 Rejected,
83}
84
85/// Which projection initiated the action. MCP is a *source*, not an operation.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Source {
89 Tui,
90 Gui,
91 Browser,
92 Mcp,
93 Headless,
94}
95
96/// The actor who performed the operation. An agent has its **own** identity, so agent
97/// actions are distinguishable from human actions (ADR-0007, ADR-0010).
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct Actor {
100 pub kind: ActorKind,
101 pub name: String,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum ActorKind {
107 Human,
108 Agent,
109}
110
111/// A single audit record. Serialized with `serde`; the same shape feeds the incident
112/// timeline export (one format, two consumers).
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct AuditEvent {
115 /// Unix epoch milliseconds — a typed instant, not a pre-formatted string, so it
116 /// sorts and localizes correctly.
117 pub timestamp: i64,
118 pub actor: Actor,
119 /// Cluster/context id — never a secret.
120 pub context: String,
121 pub operation: Operation,
122 pub target: ResourceRef,
123 pub outcome: Outcome,
124 /// Which projection initiated the action (MCP is a source, not an operation).
125 pub source: Source,
126 /// Identifies the debugging session / multi-step agent invocation, so the incident
127 /// timeline can group related events.
128 pub session_id: String,
129 /// Recorded break-glass justification (required for the break-glass guardrail to be
130 /// a complete control).
131 pub reason: Option<String>,
132 /// Who initiated the session on the agent's behalf (an agent acts under its own
133 /// ServiceAccount, but the audit question is still "who asked").
134 pub on_behalf_of: Option<String>,
135}