Skip to main content

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}