Skip to main content

kaptein_viewmodel/
semantic.rs

1//! Layer 2 — the semantic layer.
2//!
3//! The genuinely renderer-agnostic part: actions, RBAC state, status inference, blast
4//! radius. Identical for every frontend.
5//!
6//! The view-model emits **message keys + args**, never localized strings (see ADR-0005);
7//! the frontend resolves keys for i18n. Structured data (e.g. which verb/resource is
8//! forbidden) is carried as fields so the MCP surface can reason about it programmatically.
9
10use serde::{Deserialize, Serialize};
11
12/// An action the user (or an agent) can take, and whether it is currently allowed.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Action {
15    pub id: String,
16    /// Message key resolved by the frontend for i18n.
17    pub label_key: String,
18    /// RBAC-preflight result: `Allowed` (enabled) vs `Forbidden` (greyed out *before*
19    /// the user tries), possibly with a structured reason.
20    pub state: ActionState,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub enum ActionState {
25    Allowed,
26    /// Disallowed by RBAC preflight — shown greyed out, never a post-hoc 403. Carries
27    /// the specific missing permission so the MCP surface can act on it.
28    Forbidden {
29        verb: String,
30        resource: String,
31        namespace: Option<String>,
32    },
33    /// Allowed but gated behind a guardrail (e.g. prod "break glass").
34    Gated {
35        /// Message key (localized by the frontend), not a pre-formatted sentence.
36        reason_key: String,
37    },
38}
39
40/// The overall status of the current view.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub enum Status {
43    Ok,
44    Warning {
45        message_key: String,
46    },
47    Error {
48        message_key: String,
49    },
50    /// Read-only because the context is unknown or `impersonate` is unavailable.
51    ReadOnly {
52        message_key: String,
53    },
54}
55
56/// The Kubernetes RBAC `verb` an action id requires. This is the single, renderer-agnostic
57/// mapping that lets the core's RBAC preflight grey out a lens-declared action *before*
58/// the user tries it (M2.2 "per-action RBAC grey-out"). It lives here, not in a frontend,
59/// so the TUI, GUI, and MCP surface all map an action id to the same verb.
60///
61/// `describe` needs `get` (describe reads the object); `logs`, `exec`, `port-forward`
62/// need `get` too (each reads the pod); `scale`/`restart` need `update` (or `patch`);
63/// `delete` needs `delete`. Unknown action ids map to `get` (the read-only default, so an
64/// unknown action is never *less* restricted than a read).
65pub fn action_verb(action_id: &str) -> &'static str {
66    match action_id {
67        "delete" => "delete",
68        "scale" | "restart" | "update" | "apply" | "edit" | "cordon" | "drain" | "uncordon" => {
69            "update"
70        }
71        "describe" | "logs" | "exec" | "port-forward" | "diagnose" => "get",
72        _ => "get",
73    }
74}
75
76/// Downgrade an action's state to `Forbidden` when the RBAC preflight denies the verb it
77/// needs. This is the renderer-agnostic grey-out: `Allowed`/`Gated` become `Forbidden`
78/// (with the structured verb/resource/namespace the frontend and MCP surface can act on);
79/// an already-`Forbidden` action is left unchanged. A `None` preflight result means "no
80/// preflight was run" and leaves the action untouched (the caller decides whether that is
81/// fail-open or fail-closed — the shipped frontend runs preflight before rendering).
82pub fn downgrade_forbidden(
83    action: &mut Action,
84    verb_allowed: Option<bool>,
85    resource: &str,
86    namespace: Option<&str>,
87) {
88    let Some(allowed) = verb_allowed else {
89        return;
90    };
91    if allowed {
92        return;
93    }
94    if !matches!(action.state, ActionState::Forbidden { .. }) {
95        action.state = ActionState::Forbidden {
96            verb: action_verb(&action.id).to_string(),
97            resource: resource.to_string(),
98            namespace: namespace.map(str::to_string),
99        };
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn action_verb_maps_read_write_and_delete() {
109        assert_eq!(action_verb("describe"), "get");
110        assert_eq!(action_verb("logs"), "get");
111        assert_eq!(action_verb("exec"), "get");
112        assert_eq!(action_verb("diagnose"), "get");
113        assert_eq!(action_verb("scale"), "update");
114        assert_eq!(action_verb("restart"), "update");
115        assert_eq!(action_verb("delete"), "delete");
116        // Unknown ids default to the read-only verb (never *less* restricted than a read).
117        assert_eq!(action_verb("something-new"), "get");
118    }
119
120    #[test]
121    fn downgrade_allowed_to_forbidden_carries_structured_reason() {
122        let mut a = Action {
123            id: "delete".into(),
124            label_key: "action.delete".into(),
125            state: ActionState::Allowed,
126        };
127        downgrade_forbidden(&mut a, Some(false), "clusters", Some("default"));
128        assert!(matches!(
129            a.state,
130            ActionState::Forbidden { ref verb, ref resource, ref namespace }
131                if verb == "delete" && resource == "clusters" && namespace.as_deref() == Some("default")
132        ));
133    }
134
135    #[test]
136    fn downgrade_leaves_allowed_when_preflight_grants() {
137        let mut a = Action {
138            id: "describe".into(),
139            label_key: "action.describe".into(),
140            state: ActionState::Allowed,
141        };
142        downgrade_forbidden(&mut a, Some(true), "clusters", Some("default"));
143        assert!(matches!(a.state, ActionState::Allowed));
144    }
145
146    #[test]
147    fn downgrade_leaves_existing_forbidden_untouched() {
148        let mut a = Action {
149            id: "describe".into(),
150            label_key: "action.describe".into(),
151            state: ActionState::Forbidden {
152                verb: "get".into(),
153                resource: "clusters".into(),
154                namespace: Some("ns".into()),
155            },
156        };
157        downgrade_forbidden(&mut a, Some(false), "clusters", Some("default"));
158        // The pre-existing structured reason is preserved, not overwritten.
159        assert!(matches!(
160            a.state,
161            ActionState::Forbidden { ref namespace, .. } if namespace.as_deref() == Some("ns")
162        ));
163    }
164
165    #[test]
166    fn downgrade_is_a_noop_without_preflight() {
167        let mut a = Action {
168            id: "scale".into(),
169            label_key: "action.scale".into(),
170            state: ActionState::Allowed,
171        };
172        downgrade_forbidden(&mut a, None, "clusters", Some("default"));
173        assert!(matches!(a.state, ActionState::Allowed));
174    }
175}