Skip to main content

oxicode_sdk/security/
authorizer.rs

1//! Authorizer — capability-based access control with role hierarchy.
2
3use parking_lot::RwLock;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use crate::observability::AuditLog;
8use crate::security::capability::{Capability, CapabilitySet, CapabilitySubject};
9
10/// Default policy when no explicit grant matches.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum DefaultPolicy {
13    /// Deny all unknown subjects.
14    DenyAll,
15    /// Allow all (backward-compatible with pre-security code).
16    AllowAll,
17}
18
19/// Capability-based authorizer.
20///
21/// Maintains:
22/// - **Direct grants**: subject → CapabilitySet
23/// - **Roles**: role name → CapabilitySet
24/// - **Role bindings**: agent → list of role names
25///
26/// Evaluation order: direct grants → role inheritance → default policy.
27pub struct Authorizer {
28    /// Direct capability grants.
29    grants: Arc<RwLock<HashMap<CapabilitySubject, CapabilitySet>>>,
30    /// Role definitions: role name → capability set.
31    roles: Arc<RwLock<HashMap<String, CapabilitySet>>>,
32    /// Agent → role bindings.
33    role_bindings: Arc<RwLock<HashMap<String, Vec<String>>>>,
34    /// Default policy for unmatched subjects.
35    default_policy: DefaultPolicy,
36    /// Audit log for security decisions.
37    audit: Arc<AuditLog>,
38}
39
40impl Authorizer {
41    /// Create a new authorizer with deny-by-default policy.
42    pub fn new(audit: Arc<AuditLog>) -> Self {
43        Self {
44            grants: Arc::new(RwLock::new(HashMap::new())),
45            roles: Arc::new(RwLock::new(HashMap::new())),
46            role_bindings: Arc::new(RwLock::new(HashMap::new())),
47            default_policy: DefaultPolicy::DenyAll,
48            audit,
49        }
50    }
51
52    /// Create a permissive authorizer (allow all by default).
53    pub fn new_permissive(audit: Arc<AuditLog>) -> Self {
54        Self {
55            grants: Arc::new(RwLock::new(HashMap::new())),
56            roles: Arc::new(RwLock::new(HashMap::new())),
57            role_bindings: Arc::new(RwLock::new(HashMap::new())),
58            default_policy: DefaultPolicy::AllowAll,
59            audit,
60        }
61    }
62
63    // ── Direct grants ──
64
65    /// Grant a full capability set to a subject.
66    pub fn grant(&self, subject: CapabilitySubject, caps: CapabilitySet) {
67        self.grants.write().insert(subject, caps);
68    }
69
70    /// Grant a single capability to a subject.
71    pub fn grant_one(&self, subject: CapabilitySubject, cap: Capability) {
72        let mut grants = self.grants.write();
73        grants
74            .entry(subject)
75            .and_modify(|set| {
76                let mut new_caps = CapabilitySet::new(set.capabilities().to_vec());
77                new_caps.add(cap.clone());
78                *set = new_caps;
79            })
80            .or_insert_with(|| CapabilitySet::new(vec![cap]));
81    }
82
83    /// Revoke all capabilities from a subject.
84    pub fn revoke(&self, subject: &CapabilitySubject) {
85        self.grants.write().remove(subject);
86    }
87
88    // ── Role management ──
89
90    /// Define a named role with a capability set.
91    pub fn define_role(&self, role_name: &str, caps: CapabilitySet) {
92        self.roles.write().insert(role_name.to_string(), caps);
93    }
94
95    /// Bind a role to an agent.
96    pub fn bind_role(&self, agent_id: &str, role_name: &str) {
97        self.role_bindings
98            .write()
99            .entry(agent_id.to_string())
100            .or_default()
101            .push(role_name.to_string());
102    }
103
104    /// Remove a role binding from an agent.
105    pub fn unbind_role(&self, agent_id: &str, role_name: &str) {
106        if let Some(roles) = self.role_bindings.write().get_mut(agent_id) {
107            roles.retain(|r| r != role_name);
108        }
109    }
110
111    // ── Checks ──
112
113    /// Check if a subject has a required capability.
114    pub fn check(&self, subject: &CapabilitySubject, required: &Capability) -> bool {
115        let result = self.evaluate(subject, required);
116        self.audit
117            .log(crate::observability::AuditEntry::security_decision(
118                subject.to_string(),
119                format!("{:?}", required),
120                result,
121            ));
122        result
123    }
124
125    /// Require a capability, returning an error if not granted.
126    pub fn require(
127        &self,
128        subject: &CapabilitySubject,
129        required: &Capability,
130    ) -> Result<(), crate::error::SdkError> {
131        if self.check(subject, required) {
132            Ok(())
133        } else {
134            Err(crate::error::SdkError::PermissionDenied {
135                subject: subject.to_string(),
136                capability: format!("{:?}", required),
137            })
138        }
139    }
140
141    // ── Internal ──
142
143    fn evaluate(&self, subject: &CapabilitySubject, required: &Capability) -> bool {
144        // 1. Direct grants
145        let grants = self.grants.read();
146        if let Some(set) = grants.get(subject)
147            && !set.is_expired()
148            && set.satisfies(required)
149        {
150            return true;
151        }
152        drop(grants);
153
154        // 2. Role inheritance (only for agents)
155        if let CapabilitySubject::Agent(id) = subject {
156            let bindings = self.role_bindings.read();
157            if let Some(roles) = bindings.get(id) {
158                let role_defs = self.roles.read();
159                for role_name in roles {
160                    if let Some(role_caps) = role_defs.get(role_name)
161                        && !role_caps.is_expired()
162                        && role_caps.satisfies(required)
163                    {
164                        return true;
165                    }
166                }
167            }
168        }
169
170        // 3. Default policy
171        matches!(self.default_policy, DefaultPolicy::AllowAll)
172    }
173}
174
175impl std::fmt::Debug for Authorizer {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("Authorizer")
178            .field("default_policy", &self.default_policy)
179            .field("grant_count", &self.grants.read().len())
180            .field("role_count", &self.roles.read().len())
181            .finish()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn test_authorizer() -> Authorizer {
190        Authorizer::new(Arc::new(AuditLog::new(64)))
191    }
192
193    #[test]
194    fn direct_grant_check() {
195        let auth = test_authorizer();
196        auth.grant(
197            CapabilitySubject::Agent("a1".into()),
198            CapabilitySet::coding("/workspace"),
199        );
200        assert!(auth.check(
201            &CapabilitySubject::Agent("a1".into()),
202            &Capability::FileRead {
203                path_pattern: "/workspace/src/main.rs".into()
204            },
205        ));
206        assert!(!auth.check(
207            &CapabilitySubject::Agent("a1".into()),
208            &Capability::FileWrite {
209                path_pattern: "/etc/passwd".into()
210            },
211        ));
212    }
213
214    #[test]
215    fn grant_one_adds_capability() {
216        let auth = test_authorizer();
217        auth.grant_one(
218            CapabilitySubject::Agent("a1".into()),
219            Capability::FileRead {
220                path_pattern: "/ws/**".into(),
221            },
222        );
223        assert!(auth.check(
224            &CapabilitySubject::Agent("a1".into()),
225            &Capability::FileRead {
226                path_pattern: "/ws/file".into()
227            },
228        ));
229    }
230
231    #[test]
232    fn revoke_removes_all() {
233        let auth = test_authorizer();
234        let subject = CapabilitySubject::Agent("a1".into());
235        auth.grant(subject.clone(), CapabilitySet::coding("/ws"));
236        auth.revoke(&subject);
237        assert!(!auth.check(
238            &subject,
239            &Capability::FileRead {
240                path_pattern: "/ws/file".into()
241            },
242        ));
243    }
244
245    #[test]
246    fn role_based_access() {
247        let auth = test_authorizer();
248        auth.define_role("coder", CapabilitySet::coding("/workspace"));
249        auth.bind_role("agent-001", "coder");
250        assert!(auth.check(
251            &CapabilitySubject::Agent("agent-001".into()),
252            &Capability::FileRead {
253                path_pattern: "/workspace/any".into()
254            },
255        ));
256        assert!(!auth.check(
257            &CapabilitySubject::Agent("agent-001".into()),
258            &Capability::FileWrite {
259                path_pattern: "/etc/passwd".into()
260            },
261        ));
262    }
263
264    #[test]
265    fn multi_role_binding() {
266        let auth = test_authorizer();
267        auth.define_role("coder", CapabilitySet::coding("/ws"));
268        auth.define_role("browser", CapabilitySet::browser("/ws"));
269        auth.bind_role("agent-001", "coder");
270        auth.bind_role("agent-001", "browser");
271        // Coder gives file write
272        assert!(auth.check(
273            &CapabilitySubject::Agent("agent-001".into()),
274            &Capability::FileWrite {
275                path_pattern: "/ws/src/main.rs".into()
276            },
277        ));
278        // Browser gives web browse
279        assert!(auth.check(
280            &CapabilitySubject::Agent("agent-001".into()),
281            &Capability::WebBrowse {
282                allowed_domains: vec!["*".into()]
283            },
284        ));
285    }
286
287    #[test]
288    fn unbind_role() {
289        let auth = test_authorizer();
290        auth.define_role("coder", CapabilitySet::coding("/ws"));
291        auth.bind_role("a1", "coder");
292        auth.unbind_role("a1", "coder");
293        assert!(!auth.check(
294            &CapabilitySubject::Agent("a1".into()),
295            &Capability::FileRead {
296                path_pattern: "/ws/any".into()
297            },
298        ));
299    }
300
301    #[test]
302    fn deny_by_default() {
303        let auth = test_authorizer();
304        assert!(!auth.check(
305            &CapabilitySubject::Agent("unknown".into()),
306            &Capability::FileRead {
307                path_pattern: "/any".into()
308            },
309        ));
310    }
311
312    #[test]
313    fn permissive_allows_all() {
314        let auth = Authorizer::new_permissive(Arc::new(AuditLog::new(64)));
315        assert!(auth.check(
316            &CapabilitySubject::Agent("anyone".into()),
317            &Capability::FileRead {
318                path_pattern: "/any".into()
319            },
320        ));
321    }
322
323    #[test]
324    fn require_success() {
325        let auth = test_authorizer();
326        auth.grant(
327            CapabilitySubject::Agent("a1".into()),
328            CapabilitySet::read_only("/ws"),
329        );
330        assert!(
331            auth.require(
332                &CapabilitySubject::Agent("a1".into()),
333                &Capability::FileRead {
334                    path_pattern: "/ws/file".into()
335                },
336            )
337            .is_ok()
338        );
339    }
340
341    #[test]
342    fn require_failure() {
343        let auth = test_authorizer();
344        let result = auth.require(
345            &CapabilitySubject::Agent("a1".into()),
346            &Capability::FileRead {
347                path_pattern: "/ws/file".into(),
348            },
349        );
350        assert!(result.is_err());
351        let err = result.unwrap_err();
352        assert!(err.to_string().contains("permission denied"));
353    }
354}