Skip to main content

oxicode_sdk/security/
rbac.rs

1//! RBAC — role-based access control with HitL approvals.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, HashSet};
6use uuid::Uuid;
7
8/// 3-tier role model.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum Role {
11    /// Basic — limited tools, workspace only.
12    User,
13    /// All tools, agent/program/workspace management.
14    Superuser,
15    /// Full access, RBAC management.
16    Admin,
17}
18
19impl Role {
20    /// Default policy for this role.
21    pub fn default_policy(&self) -> RbacPolicy {
22        match self {
23            Role::Admin => RbacPolicy {
24                role: Role::Admin,
25                allowed_actions: vec![
26                    Action::UseTool("*".into()),
27                    Action::AccessPath("*".into()),
28                    Action::ManageAgents,
29                    Action::ManagePrograms,
30                    Action::ManageWorkspaces,
31                    Action::ManageRBAC,
32                    Action::ViewAuditLog,
33                    Action::SystemConfig,
34                ]
35                .into_iter()
36                .collect(),
37                resource_patterns: vec!["*".into()],
38                max_concurrent_agents: usize::MAX,
39            },
40            Role::Superuser => RbacPolicy {
41                role: Role::Superuser,
42                allowed_actions: vec![
43                    Action::UseTool("*".into()),
44                    Action::AccessPath("/workspace/**".into()),
45                    Action::ManageAgents,
46                    Action::ManagePrograms,
47                    Action::ManageWorkspaces,
48                    Action::ViewAuditLog,
49                ]
50                .into_iter()
51                .collect(),
52                resource_patterns: vec!["/workspace/**".into(), "/tmp/**".into()],
53                max_concurrent_agents: 10,
54            },
55            Role::User => RbacPolicy {
56                role: Role::User,
57                allowed_actions: vec![
58                    Action::UseTool("read".into()),
59                    Action::UseTool("write".into()),
60                    Action::UseTool("edit".into()),
61                    Action::UseTool("bash".into()),
62                    Action::UseTool("grep".into()),
63                    Action::UseTool("find".into()),
64                    Action::AccessPath("/workspace/**".into()),
65                    Action::ManageAgents,
66                ]
67                .into_iter()
68                .collect(),
69                resource_patterns: vec!["/workspace/**".into()],
70                max_concurrent_agents: 2,
71            },
72        }
73    }
74}
75
76/// Who is accessing the system.
77#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub enum Subject {
79    /// Named user.
80    User(String),
81    /// Agent acting on behalf of a user.
82    Agent(Uuid),
83    /// System-level (bypasses RBAC).
84    System,
85}
86
87impl std::fmt::Display for Subject {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Subject::User(name) => write!(f, "user:{name}"),
91            Subject::Agent(id) => write!(f, "agent:{id}"),
92            Subject::System => write!(f, "system"),
93        }
94    }
95}
96
97/// Authorizable actions.
98#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
99pub enum Action {
100    /// Use a specific tool (name or *).
101    UseTool(String),
102    /// Access a path pattern.
103    AccessPath(String),
104    /// Manage agents.
105    ManageAgents,
106    /// Manage programs.
107    ManagePrograms,
108    /// Manage workspaces.
109    ManageWorkspaces,
110    /// Modify RBAC.
111    ManageRBAC,
112    /// View audit log.
113    ViewAuditLog,
114    /// System configuration.
115    SystemConfig,
116}
117
118impl Action {
119    /// Whether this action needs HitL approval.
120    pub fn requires_approval(&self) -> bool {
121        match self {
122            Action::ManageRBAC | Action::SystemConfig => true,
123            Action::UseTool(t) => t == "*" || t == "osascript" || t == "rm",
124            _ => false,
125        }
126    }
127}
128
129/// RBAC policy for a role.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RbacPolicy {
132    /// The role.
133    pub role: Role,
134    /// Allowed actions.
135    pub allowed_actions: HashSet<Action>,
136    /// Resource glob patterns.
137    pub resource_patterns: Vec<String>,
138    /// Max concurrent agents.
139    pub max_concurrent_agents: usize,
140}
141
142impl RbacPolicy {
143    /// Check if this policy allows an action (exact + wildcard).
144    pub fn allows(&self, action: &Action) -> bool {
145        if self.allowed_actions.contains(action) {
146            return true;
147        }
148        match action {
149            Action::UseTool(tool_name) => {
150                self.allowed_actions
151                    .iter()
152                    .any(|a| matches!(a, Action::UseTool(w) if w == "*"))
153                    || self
154                        .allowed_actions
155                        .contains(&Action::UseTool(tool_name.clone()))
156            }
157            Action::AccessPath(path) => {
158                self.allowed_actions
159                    .iter()
160                    .any(|a| matches!(a, Action::AccessPath(p) if p == "*"))
161                    || self
162                        .allowed_actions
163                        .contains(&Action::AccessPath(path.clone()))
164            }
165            _ => false,
166        }
167    }
168}
169
170/// RBAC audit entry.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct RbacAuditEntry {
173    /// When the access decision was recorded.
174    pub timestamp: DateTime<Utc>,
175    /// Who performed the access attempt.
176    pub subject: Subject,
177    /// Action that was requested.
178    pub action: Action,
179    /// Resource the action targeted.
180    pub resource: String,
181    /// Whether the action was permitted.
182    pub allowed: bool,
183    /// Optional human-readable explanation of the decision.
184    pub reason: Option<String>,
185}
186
187/// Pending HitL approval.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct PendingApproval {
190    /// Unique identifier for this approval request.
191    pub id: Uuid,
192    /// Subject requesting the gated action.
193    pub subject: Subject,
194    /// Action requiring approval.
195    pub action: Action,
196    /// Resource the action targets.
197    pub resource: String,
198    /// Human-readable justification for the request.
199    pub reason: String,
200    /// When the approval was requested.
201    pub created_at: DateTime<Utc>,
202}
203
204/// Approval status.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206pub enum ApprovalStatus {
207    /// Awaiting a human decision.
208    Pending,
209    /// A human reviewer granted the request.
210    Approved,
211    /// A human reviewer denied the request.
212    Rejected,
213    /// The request expired before a decision was made.
214    Expired,
215}
216
217/// RBAC Manager — roles, policies, audit, and HitL approvals.
218#[derive(Debug, Clone)]
219pub struct RbacManager {
220    policies: HashMap<Role, RbacPolicy>,
221    subject_roles: HashMap<Subject, Role>,
222    audit_log: Vec<RbacAuditEntry>,
223    pending_approvals: Vec<(PendingApproval, ApprovalStatus)>,
224    max_audit_entries: usize,
225}
226
227impl RbacManager {
228    /// Create with default policies for all roles.
229    pub fn new() -> Self {
230        let mut this = Self {
231            policies: HashMap::new(),
232            subject_roles: HashMap::new(),
233            audit_log: Vec::new(),
234            pending_approvals: Vec::new(),
235            max_audit_entries: 10_000,
236        };
237        for role in [Role::User, Role::Superuser, Role::Admin] {
238            this.policies.insert(role, role.default_policy());
239        }
240        this
241    }
242
243    /// Assign a role.
244    pub fn assign_role(&mut self, subject: Subject, role: Role) {
245        self.subject_roles.insert(subject, role);
246    }
247
248    /// Revoke a role.
249    pub fn revoke_role(&mut self, subject: &Subject) {
250        self.subject_roles.remove(subject);
251    }
252
253    /// Get role for a subject.
254    pub fn get_role(&self, subject: &Subject) -> Option<Role> {
255        self.subject_roles.get(subject).copied()
256    }
257
258    /// Check permission + audit.
259    pub fn check_permission(&mut self, subject: &Subject, action: &Action, resource: &str) -> bool {
260        if matches!(subject, Subject::System) {
261            return true;
262        }
263        let role = match self.subject_roles.get(subject) {
264            Some(r) => *r,
265            None => return false,
266        };
267        let policy = match self.policies.get(&role) {
268            Some(p) => p,
269            None => return false,
270        };
271        let allowed = policy.allows(action);
272        self.audit_log.push(RbacAuditEntry {
273            timestamp: Utc::now(),
274            subject: subject.clone(),
275            action: action.clone(),
276            resource: resource.to_string(),
277            allowed,
278            reason: if allowed {
279                None
280            } else {
281                Some(format!("role {role:?} does not allow {action:?}"))
282            },
283        });
284        if self.audit_log.len() > self.max_audit_entries {
285            self.audit_log
286                .drain(0..self.audit_log.len() - self.max_audit_entries);
287        }
288        allowed
289    }
290
291    /// Request HitL approval.
292    pub fn request_approval(
293        &mut self,
294        subject: Subject,
295        action: Action,
296        resource: String,
297        reason: String,
298    ) -> Uuid {
299        let id = Uuid::new_v4();
300        self.pending_approvals.push((
301            PendingApproval {
302                id,
303                subject,
304                action,
305                resource,
306                reason,
307                created_at: Utc::now(),
308            },
309            ApprovalStatus::Pending,
310        ));
311        id
312    }
313
314    /// Approve a request.
315    pub fn approve(&mut self, id: Uuid) -> bool {
316        if let Some((_, s)) = self
317            .pending_approvals
318            .iter_mut()
319            .find(|(p, s)| p.id == id && *s == ApprovalStatus::Pending)
320        {
321            *s = ApprovalStatus::Approved;
322            return true;
323        }
324        false
325    }
326
327    /// Reject a request.
328    pub fn reject(&mut self, id: Uuid) -> bool {
329        if let Some((_, s)) = self
330            .pending_approvals
331            .iter_mut()
332            .find(|(p, s)| p.id == id && *s == ApprovalStatus::Pending)
333        {
334            *s = ApprovalStatus::Rejected;
335            return true;
336        }
337        false
338    }
339
340    /// Pending approvals.
341    pub fn pending_approvals(&self) -> Vec<&PendingApproval> {
342        self.pending_approvals
343            .iter()
344            .filter(|(_, s)| matches!(s, ApprovalStatus::Pending))
345            .map(|(p, _)| p)
346            .collect()
347    }
348
349    /// All approvals with status.
350    pub fn all_approvals(&self) -> &[(PendingApproval, ApprovalStatus)] {
351        &self.pending_approvals
352    }
353
354    /// Audit log.
355    pub fn audit_log(&self) -> &[RbacAuditEntry] {
356        &self.audit_log
357    }
358}
359
360impl Default for RbacManager {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn role_assignment() {
372        let mut mgr = RbacManager::new();
373        let s = Subject::User("alice".into());
374        mgr.assign_role(s.clone(), Role::Admin);
375        assert_eq!(mgr.get_role(&s), Some(Role::Admin));
376        mgr.revoke_role(&s);
377        assert_eq!(mgr.get_role(&s), None);
378    }
379
380    #[test]
381    fn system_bypasses() {
382        let mut mgr = RbacManager::new();
383        assert!(mgr.check_permission(&Subject::System, &Action::ManageRBAC, "test"));
384    }
385
386    #[test]
387    fn unknown_denied() {
388        let mut mgr = RbacManager::new();
389        assert!(!mgr.check_permission(
390            &Subject::User("nobody".into()),
391            &Action::UseTool("read".into()),
392            "test"
393        ));
394    }
395
396    #[test]
397    fn admin_wildcard() {
398        let mut mgr = RbacManager::new();
399        let s = Subject::User("admin".into());
400        mgr.assign_role(s.clone(), Role::Admin);
401        assert!(mgr.check_permission(&s, &Action::UseTool("anything".into()), "test"));
402    }
403
404    #[test]
405    fn approval_lifecycle() {
406        let mut mgr = RbacManager::new();
407        let id = mgr.request_approval(
408            Subject::User("alice".into()),
409            Action::ManageRBAC,
410            "rbac".into(),
411            "need admin".into(),
412        );
413        assert_eq!(mgr.pending_approvals().len(), 1);
414        assert!(mgr.approve(id));
415        assert!(mgr.pending_approvals().is_empty());
416        assert!(!mgr.approve(id)); // already approved
417    }
418}