Skip to main content

zeroclaw_runtime/security/
iam_policy.rs

1//! IAM-aware policy enforcement for Nevis role-to-permission mapping.
2//!
3//! Evaluates tool and workspace access based on Nevis roles using a
4//! deny-by-default policy model. All policy decisions are audit-logged.
5
6use super::nevis::NevisIdentity;
7use anyhow::{Result, bail};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Maps a single Nevis role to ZeroClaw permissions.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct RoleMapping {
14    /// Nevis role name (case-insensitive matching).
15    pub nevis_role: String,
16    /// Tool names this role can access. Use `"all"` to grant all tools.
17    pub zeroclaw_permissions: Vec<String>,
18    /// Workspace names this role can access. Use `"all"` for unrestricted.
19    #[serde(default)]
20    pub workspace_access: Vec<String>,
21}
22
23/// Result of a policy evaluation.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum PolicyDecision {
26    /// Access is allowed.
27    Allow,
28    /// Access is denied, with reason.
29    Deny(String),
30}
31
32impl PolicyDecision {
33    pub fn is_allowed(&self) -> bool {
34        matches!(self, PolicyDecision::Allow)
35    }
36}
37
38/// IAM policy engine that maps Nevis roles to ZeroClaw tool permissions.
39///
40/// Deny-by-default: if no role mapping grants access, the request is denied.
41#[derive(Debug, Clone)]
42pub struct IamPolicy {
43    /// Compiled role mappings indexed by lowercase Nevis role name.
44    role_map: HashMap<String, CompiledRole>,
45}
46
47#[derive(Debug, Clone)]
48struct CompiledRole {
49    /// Whether this role has access to all tools.
50    all_tools: bool,
51    /// Specific tool names this role can access (lowercase).
52    allowed_tools: Vec<String>,
53    /// Whether this role has access to all workspaces.
54    all_workspaces: bool,
55    /// Specific workspace names this role can access (lowercase).
56    allowed_workspaces: Vec<String>,
57}
58
59impl IamPolicy {
60    /// Build a policy from role mappings (typically from config).
61    ///
62    /// Returns an error if duplicate normalized role names are detected,
63    /// since silent last-wins overwrites can accidentally broaden or revoke access.
64    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    /// Evaluate whether an identity is allowed to use a specific tool.
119    ///
120    /// Deny-by-default: returns `Deny` unless at least one of the identity's
121    /// roles grants access to the requested tool.
122    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    /// Evaluate whether an identity is allowed to access a specific workspace.
152    ///
153    /// Deny-by-default: returns `Deny` unless at least one of the identity's
154    /// roles grants access to the requested workspace.
155    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    /// Check if the policy has any role mappings configured.
188    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        // viewer has file_read, operator has shell — both should be accessible
361        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}