zeroclaw_runtime/security/
iam_policy.rs1use super::nevis::NevisIdentity;
7use anyhow::{Result, bail};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct RoleMapping {
14 pub nevis_role: String,
16 pub zeroclaw_permissions: Vec<String>,
18 #[serde(default)]
20 pub workspace_access: Vec<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum PolicyDecision {
26 Allow,
28 Deny(String),
30}
31
32impl PolicyDecision {
33 pub fn is_allowed(&self) -> bool {
34 matches!(self, PolicyDecision::Allow)
35 }
36}
37
38#[derive(Debug, Clone)]
42pub struct IamPolicy {
43 role_map: HashMap<String, CompiledRole>,
45}
46
47#[derive(Debug, Clone)]
48struct CompiledRole {
49 all_tools: bool,
51 allowed_tools: Vec<String>,
53 all_workspaces: bool,
55 allowed_workspaces: Vec<String>,
57}
58
59impl IamPolicy {
60 pub fn from_mappings(mappings: &[RoleMapping]) -> Result<Self> {
65 let mut role_map = HashMap::new();
66
67 for mapping in mappings {
68 let key = mapping.nevis_role.trim().to_ascii_lowercase();
69 if key.is_empty() {
70 continue;
71 }
72
73 let all_tools = mapping
74 .zeroclaw_permissions
75 .iter()
76 .any(|p| p.eq_ignore_ascii_case("all"));
77 let allowed_tools: Vec<String> = mapping
78 .zeroclaw_permissions
79 .iter()
80 .filter(|p| !p.eq_ignore_ascii_case("all"))
81 .map(|p| p.trim().to_ascii_lowercase())
82 .collect();
83
84 let all_workspaces = mapping
85 .workspace_access
86 .iter()
87 .any(|w| w.eq_ignore_ascii_case("all"));
88 let allowed_workspaces: Vec<String> = mapping
89 .workspace_access
90 .iter()
91 .filter(|w| !w.eq_ignore_ascii_case("all"))
92 .map(|w| w.trim().to_ascii_lowercase())
93 .collect();
94
95 if role_map.contains_key(&key) {
96 bail!(
97 "IAM policy: duplicate role mapping for normalized key '{}' \
98 (from nevis_role '{}') — remove or merge the duplicate entry",
99 key,
100 mapping.nevis_role
101 );
102 }
103
104 role_map.insert(
105 key,
106 CompiledRole {
107 all_tools,
108 allowed_tools,
109 all_workspaces,
110 allowed_workspaces,
111 },
112 );
113 }
114
115 Ok(Self { role_map })
116 }
117
118 pub fn evaluate_tool_access(
123 &self,
124 identity: &NevisIdentity,
125 tool_name: &str,
126 ) -> PolicyDecision {
127 let normalized_tool = tool_name.trim().to_ascii_lowercase();
128 if normalized_tool.is_empty() {
129 return PolicyDecision::Deny("empty tool name".into());
130 }
131
132 for role in &identity.roles {
133 let key = role.trim().to_ascii_lowercase();
134 if let Some(compiled) = self.role_map.get(&key)
135 && (compiled.all_tools
136 || compiled.allowed_tools.iter().any(|t| t == &normalized_tool))
137 {
138 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"user_id": crate::security::redact(&identity.user_id), "role": key, "tool": normalized_tool})), "IAM policy: tool access ALLOWED");
139 return PolicyDecision::Allow;
140 }
141 }
142
143 let reason = format!(
144 "no role grants access to tool '{normalized_tool}' for user '{}'",
145 crate::security::redact(&identity.user_id)
146 );
147 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"user_id": crate::security::redact(&identity.user_id), "tool": normalized_tool})), "IAM policy: tool access DENIED");
148 PolicyDecision::Deny(reason)
149 }
150
151 pub fn evaluate_workspace_access(
156 &self,
157 identity: &NevisIdentity,
158 workspace: &str,
159 ) -> PolicyDecision {
160 let normalized_ws = workspace.trim().to_ascii_lowercase();
161 if normalized_ws.is_empty() {
162 return PolicyDecision::Deny("empty workspace name".into());
163 }
164
165 for role in &identity.roles {
166 let key = role.trim().to_ascii_lowercase();
167 if let Some(compiled) = self.role_map.get(&key)
168 && (compiled.all_workspaces
169 || compiled
170 .allowed_workspaces
171 .iter()
172 .any(|w| w == &normalized_ws))
173 {
174 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"user_id": crate::security::redact(&identity.user_id), "role": key, "workspace": normalized_ws})), "IAM policy: workspace access ALLOWED");
175 return PolicyDecision::Allow;
176 }
177 }
178
179 let reason = format!(
180 "no role grants access to workspace '{normalized_ws}' for user '{}'",
181 crate::security::redact(&identity.user_id)
182 );
183 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"user_id": crate::security::redact(&identity.user_id), "workspace": normalized_ws})), "IAM policy: workspace access DENIED");
184 PolicyDecision::Deny(reason)
185 }
186
187 pub fn is_empty(&self) -> bool {
189 self.role_map.is_empty()
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn test_mappings() -> Vec<RoleMapping> {
198 vec![
199 RoleMapping {
200 nevis_role: "admin".into(),
201 zeroclaw_permissions: vec!["all".into()],
202 workspace_access: vec!["all".into()],
203 },
204 RoleMapping {
205 nevis_role: "operator".into(),
206 zeroclaw_permissions: vec![
207 "shell".into(),
208 "file_read".into(),
209 "file_write".into(),
210 "memory_search".into(),
211 ],
212 workspace_access: vec!["production".into(), "staging".into()],
213 },
214 RoleMapping {
215 nevis_role: "viewer".into(),
216 zeroclaw_permissions: vec!["file_read".into(), "memory_search".into()],
217 workspace_access: vec!["staging".into()],
218 },
219 ]
220 }
221
222 fn identity_with_roles(roles: Vec<&str>) -> NevisIdentity {
223 NevisIdentity {
224 user_id: "zeroclaw_user".into(),
225 roles: roles.into_iter().map(String::from).collect(),
226 scopes: vec!["openid".into()],
227 mfa_verified: true,
228 session_expiry: u64::MAX,
229 }
230 }
231
232 #[test]
233 fn admin_gets_all_tools() {
234 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
235 let identity = identity_with_roles(vec!["admin"]);
236
237 assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
238 assert!(
239 policy
240 .evaluate_tool_access(&identity, "file_read")
241 .is_allowed()
242 );
243 assert!(
244 policy
245 .evaluate_tool_access(&identity, "any_tool_name")
246 .is_allowed()
247 );
248 }
249
250 #[test]
251 fn admin_gets_all_workspaces() {
252 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
253 let identity = identity_with_roles(vec!["admin"]);
254
255 assert!(
256 policy
257 .evaluate_workspace_access(&identity, "production")
258 .is_allowed()
259 );
260 assert!(
261 policy
262 .evaluate_workspace_access(&identity, "any_workspace")
263 .is_allowed()
264 );
265 }
266
267 #[test]
268 fn operator_gets_subset_of_tools() {
269 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
270 let identity = identity_with_roles(vec!["operator"]);
271
272 assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
273 assert!(
274 policy
275 .evaluate_tool_access(&identity, "file_read")
276 .is_allowed()
277 );
278 assert!(
279 !policy
280 .evaluate_tool_access(&identity, "browser")
281 .is_allowed()
282 );
283 }
284
285 #[test]
286 fn operator_workspace_access_is_scoped() {
287 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
288 let identity = identity_with_roles(vec!["operator"]);
289
290 assert!(
291 policy
292 .evaluate_workspace_access(&identity, "production")
293 .is_allowed()
294 );
295 assert!(
296 policy
297 .evaluate_workspace_access(&identity, "staging")
298 .is_allowed()
299 );
300 assert!(
301 !policy
302 .evaluate_workspace_access(&identity, "development")
303 .is_allowed()
304 );
305 }
306
307 #[test]
308 fn viewer_is_read_only() {
309 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
310 let identity = identity_with_roles(vec!["viewer"]);
311
312 assert!(
313 policy
314 .evaluate_tool_access(&identity, "file_read")
315 .is_allowed()
316 );
317 assert!(
318 policy
319 .evaluate_tool_access(&identity, "memory_search")
320 .is_allowed()
321 );
322 assert!(!policy.evaluate_tool_access(&identity, "shell").is_allowed());
323 assert!(
324 !policy
325 .evaluate_tool_access(&identity, "file_write")
326 .is_allowed()
327 );
328 }
329
330 #[test]
331 fn deny_by_default_for_unknown_role() {
332 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
333 let identity = identity_with_roles(vec!["unknown_role"]);
334
335 assert!(!policy.evaluate_tool_access(&identity, "shell").is_allowed());
336 assert!(
337 !policy
338 .evaluate_workspace_access(&identity, "production")
339 .is_allowed()
340 );
341 }
342
343 #[test]
344 fn deny_by_default_for_no_roles() {
345 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
346 let identity = identity_with_roles(vec![]);
347
348 assert!(
349 !policy
350 .evaluate_tool_access(&identity, "file_read")
351 .is_allowed()
352 );
353 }
354
355 #[test]
356 fn multiple_roles_union_permissions() {
357 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
358 let identity = identity_with_roles(vec!["viewer", "operator"]);
359
360 assert!(
362 policy
363 .evaluate_tool_access(&identity, "file_read")
364 .is_allowed()
365 );
366 assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
367 }
368
369 #[test]
370 fn role_matching_is_case_insensitive() {
371 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
372 let identity = identity_with_roles(vec!["ADMIN"]);
373
374 assert!(policy.evaluate_tool_access(&identity, "shell").is_allowed());
375 }
376
377 #[test]
378 fn tool_matching_is_case_insensitive() {
379 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
380 let identity = identity_with_roles(vec!["operator"]);
381
382 assert!(policy.evaluate_tool_access(&identity, "SHELL").is_allowed());
383 assert!(
384 policy
385 .evaluate_tool_access(&identity, "File_Read")
386 .is_allowed()
387 );
388 }
389
390 #[test]
391 fn empty_tool_name_is_denied() {
392 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
393 let identity = identity_with_roles(vec!["admin"]);
394
395 assert!(!policy.evaluate_tool_access(&identity, "").is_allowed());
396 assert!(!policy.evaluate_tool_access(&identity, " ").is_allowed());
397 }
398
399 #[test]
400 fn empty_workspace_name_is_denied() {
401 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
402 let identity = identity_with_roles(vec!["admin"]);
403
404 assert!(!policy.evaluate_workspace_access(&identity, "").is_allowed());
405 }
406
407 #[test]
408 fn empty_mappings_deny_everything() {
409 let policy = IamPolicy::from_mappings(&[]).unwrap();
410 let identity = identity_with_roles(vec!["admin"]);
411
412 assert!(policy.is_empty());
413 assert!(!policy.evaluate_tool_access(&identity, "shell").is_allowed());
414 }
415
416 #[test]
417 fn policy_decision_deny_contains_reason() {
418 let policy = IamPolicy::from_mappings(&test_mappings()).unwrap();
419 let identity = identity_with_roles(vec!["viewer"]);
420
421 let decision = policy.evaluate_tool_access(&identity, "shell");
422 match decision {
423 PolicyDecision::Deny(reason) => {
424 assert!(reason.contains("shell"));
425 }
426 PolicyDecision::Allow => panic!("expected deny"),
427 }
428 }
429
430 #[test]
431 fn duplicate_normalized_roles_are_rejected() {
432 let mappings = vec![
433 RoleMapping {
434 nevis_role: "admin".into(),
435 zeroclaw_permissions: vec!["all".into()],
436 workspace_access: vec!["all".into()],
437 },
438 RoleMapping {
439 nevis_role: " ADMIN ".into(),
440 zeroclaw_permissions: vec!["file_read".into()],
441 workspace_access: vec![],
442 },
443 ];
444 let err = IamPolicy::from_mappings(&mappings).unwrap_err();
445 assert!(
446 err.to_string().contains("duplicate role mapping"),
447 "Expected duplicate role error, got: {err}"
448 );
449 }
450
451 #[test]
452 fn empty_role_name_in_mapping_is_skipped() {
453 let mappings = vec![RoleMapping {
454 nevis_role: " ".into(),
455 zeroclaw_permissions: vec!["all".into()],
456 workspace_access: vec![],
457 }];
458 let policy = IamPolicy::from_mappings(&mappings).unwrap();
459 assert!(policy.is_empty());
460 }
461}