1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Action {
15 pub id: String,
16 pub label_key: String,
18 pub state: ActionState,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub enum ActionState {
25 Allowed,
26 Forbidden {
29 verb: String,
30 resource: String,
31 namespace: Option<String>,
32 },
33 Gated {
35 reason_key: String,
37 },
38}
39
40#[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 ReadOnly {
52 message_key: String,
53 },
54}
55
56pub 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
76pub 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 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 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}