Skip to main content

oxios_kernel/access_manager/
rbac.rs

1//! RBAC types and manager — role-based access control with HitL approvals.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, HashSet};
6
7use crate::types::AgentId;
8
9// ─── RBAC Types ───────────────────────────────────────────────────────────────
10
11/// Roles for role-based access control (3-tier model).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum Role {
14    /// Basic user — can use agents, limited permissions.
15    User,
16    /// Superuser — can manage programs, skills, workspaces.
17    Superuser,
18    /// Admin — full system access, can modify RBAC.
19    Admin,
20}
21
22impl Role {
23    /// Returns the default policy for this role.
24    pub fn default_policy(&self) -> RbacPolicy {
25        match self {
26            Role::Admin => RbacPolicy {
27                role: Role::Admin,
28                allowed_actions: vec![
29                    Action::UseTool("*".into()),
30                    Action::AccessPath("*".into()),
31                    Action::ManageAgents,
32                    Action::ManagePrograms,
33                    Action::ManageWorkspaces,
34                    Action::ManageRBAC,
35                    Action::ViewAuditLog,
36                    Action::SystemConfig,
37                ]
38                .into_iter()
39                .collect(),
40                resource_patterns: vec!["*".into()],
41                max_concurrent_agents: usize::MAX,
42            },
43            Role::Superuser => RbacPolicy {
44                role: Role::Superuser,
45                allowed_actions: vec![
46                    Action::UseTool("*".into()),
47                    Action::AccessPath("*".into()),
48                    Action::ManageAgents,
49                    Action::ManagePrograms,
50                    Action::ManageWorkspaces,
51                    Action::ViewAuditLog,
52                ]
53                .into_iter()
54                .collect(),
55                resource_patterns: vec!["*".into()],
56                max_concurrent_agents: 10,
57            },
58            Role::User => RbacPolicy {
59                role: Role::User,
60                allowed_actions: vec![
61                    Action::UseTool("read".into()),
62                    Action::UseTool("write".into()),
63                    Action::UseTool("edit".into()),
64                    Action::UseTool("bash".into()),
65                    Action::UseTool("grep".into()),
66                    Action::UseTool("find".into()),
67                    Action::AccessPath("/workspace/**".into()),
68                    Action::ManageAgents,
69                ]
70                .into_iter()
71                .collect(),
72                resource_patterns: vec!["/workspace/**".into()],
73                max_concurrent_agents: 2,
74            },
75        }
76    }
77}
78
79/// Subject — who is accessing the system.
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub enum Subject {
82    /// A named user.
83    User(String),
84    /// An agent acting on behalf of a user.
85    Agent(AgentId),
86    /// System-level operations (bypass RBAC).
87    System,
88}
89
90impl std::fmt::Display for Subject {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Subject::User(name) => write!(f, "user:{name}"),
94            Subject::Agent(id) => write!(f, "agent:{id}"),
95            Subject::System => write!(f, "system"),
96        }
97    }
98}
99
100/// Actions that can be authorized by RBAC.
101#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
102pub enum Action {
103    /// Use a specific tool (name or * for all).
104    UseTool(String),
105    /// Access a specific path pattern (glob).
106    AccessPath(String),
107    /// Manage agents (fork/exec/kill).
108    ManageAgents,
109    /// Manage programs (install/uninstall).
110    ManagePrograms,
111    /// Manage workspaces (create/start/stop/remove).
112    ManageWorkspaces,
113    /// Modify RBAC policies and role assignments.
114    ManageRBAC,
115    /// View the audit log.
116    ViewAuditLog,
117    /// Modify system-level configuration.
118    SystemConfig,
119}
120
121impl Action {
122    /// Returns true if this action is considered high-risk and needs HitL approval.
123    pub fn requires_approval(&self) -> bool {
124        match self {
125            Action::ManageRBAC | Action::SystemConfig => true,
126            Action::UseTool(t) => t == "*" || t == "osascript" || t == "rm",
127            _ => false,
128        }
129    }
130}
131
132/// RBAC policy defining what a role can do.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct RbacPolicy {
135    /// The role this policy applies to.
136    pub role: Role,
137    /// Set of actions this role is allowed to perform.
138    pub allowed_actions: HashSet<Action>,
139    /// Glob patterns for accessible resources.
140    pub resource_patterns: Vec<String>,
141    /// Maximum number of concurrent agents for this role.
142    pub max_concurrent_agents: usize,
143}
144
145impl RbacPolicy {
146    /// Checks whether this policy allows the given action.
147    ///
148    /// Supports wildcard matching for `UseTool("*")` (matches any tool name)
149    /// and `AccessPath("*")` (matches any path).
150    pub fn allows(&self, action: &Action) -> bool {
151        // First, try exact match.
152        if self.allowed_actions.contains(action) {
153            return true;
154        }
155
156        // Then, check wildcard patterns.
157        match action {
158            Action::UseTool(tool_name) => {
159                // Check if policy has UseTool("*") wildcard.
160                self.allowed_actions
161                    .iter()
162                    .any(|a| matches!(a, Action::UseTool(w) if w == "*"))
163                    // Also check if the specific tool name is listed.
164                    || self.allowed_actions.contains(&Action::UseTool(tool_name.clone()))
165            }
166            Action::AccessPath(path) => {
167                // Wildcard or exact match in allowed_actions.
168                if self
169                    .allowed_actions
170                    .iter()
171                    .any(|a| matches!(a, Action::AccessPath(p) if p == "*"))
172                    || self
173                        .allowed_actions
174                        .contains(&Action::AccessPath(path.clone()))
175                {
176                    return true;
177                }
178                // Enforce resource_patterns glob match (e.g. "/workspace/**").
179                // Previously this field was defined but never consulted.
180                for pattern in &self.resource_patterns {
181                    if pattern == "*" {
182                        return true;
183                    }
184                    if let Ok(p) = glob::Pattern::new(pattern)
185                        && p.matches(path)
186                    {
187                        return true;
188                    }
189                }
190                false
191            }
192            // Non-parameterized actions: exact match only (already checked above).
193            _ => false,
194        }
195    }
196}
197
198/// RBAC audit entry — records authorization decisions.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct RbacAuditEntry {
201    /// When the authorization decision was made.
202    pub timestamp: DateTime<Utc>,
203    /// Who performed the action.
204    pub subject: Subject,
205    /// What action was attempted.
206    pub action: Action,
207    /// Which resource was involved.
208    pub resource: String,
209    /// Whether the action was allowed.
210    pub allowed: bool,
211    /// Optional reason for the decision.
212    pub reason: Option<String>,
213}
214
215impl RbacAuditEntry {
216    /// Creates a new RBAC audit entry.
217    pub(crate) fn new(
218        subject: Subject,
219        action: Action,
220        resource: String,
221        allowed: bool,
222        reason: Option<String>,
223    ) -> Self {
224        Self {
225            timestamp: Utc::now(),
226            subject,
227            action,
228            resource,
229            allowed,
230            reason,
231        }
232    }
233}
234
235/// Human-in-the-loop approval request.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct PendingApproval {
238    /// Unique identifier for this approval request.
239    pub id: uuid::Uuid,
240    /// Who is requesting the action.
241    pub subject: Subject,
242    /// What action is being requested.
243    pub action: Action,
244    /// Which resource is involved.
245    pub resource: String,
246    /// Why the action needs approval.
247    pub reason: String,
248    /// When the request was created.
249    pub created_at: DateTime<Utc>,
250}
251
252/// Status of a HitL approval request.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254pub enum ApprovalStatus {
255    /// Awaiting user decision.
256    Pending,
257    /// User approved the request.
258    Approved,
259    /// User rejected the request.
260    Rejected,
261    /// Request timed out.
262    Expired,
263}
264
265/// RBAC Manager — manages roles, permissions, and HitL approvals.
266#[derive(Debug, Clone)]
267pub struct RbacManager {
268    policies: HashMap<Role, RbacPolicy>,
269    subject_roles: HashMap<Subject, Role>,
270    audit_log: Vec<RbacAuditEntry>,
271    pending_approvals: Vec<(PendingApproval, ApprovalStatus)>,
272    max_audit_entries: usize,
273}
274
275impl RbacManager {
276    /// Creates a new RBAC manager with default policies for all roles.
277    pub fn new() -> Self {
278        let mut this = Self {
279            policies: HashMap::new(),
280            subject_roles: HashMap::new(),
281            audit_log: Vec::new(),
282            pending_approvals: Vec::new(),
283            max_audit_entries: 10_000,
284        };
285        for role in [Role::User, Role::Superuser, Role::Admin] {
286            this.policies.insert(role, role.default_policy());
287        }
288        this
289    }
290
291    /// Assigns a role to a subject.
292    pub fn assign_role(&mut self, subject: Subject, role: Role) {
293        self.subject_roles.insert(subject.clone(), role);
294    }
295
296    /// Revokes the role from a subject.
297    pub fn revoke_role(&mut self, subject: &Subject) {
298        self.subject_roles.remove(subject);
299    }
300
301    /// Returns the role assigned to a subject, if any.
302    pub fn get_role(&self, subject: &Subject) -> Option<Role> {
303        self.subject_roles.get(subject).copied()
304    }
305
306    /// Checks whether a subject has permission for the given action on a resource.
307    pub fn check_permission(&mut self, subject: &Subject, action: &Action, resource: &str) -> bool {
308        if matches!(subject, Subject::System) {
309            // System subject bypasses role checks, but the decision is still
310            // recorded in the audit trail so bypasses are visible (never silent).
311            self.audit_log.push(RbacAuditEntry::new(
312                subject.clone(),
313                action.clone(),
314                resource.to_string(),
315                true,
316                Some("system subject bypass".to_string()),
317            ));
318            if self.audit_log.len() > self.max_audit_entries {
319                self.audit_log
320                    .drain(0..self.audit_log.len() - self.max_audit_entries);
321            }
322            return true;
323        }
324        let role = match self.subject_roles.get(subject) {
325            Some(r) => *r,
326            None => return false,
327        };
328        let policy = match self.policies.get(&role) {
329            Some(p) => p,
330            None => return false,
331        };
332        let allowed = policy.allows(action);
333        self.audit_log.push(RbacAuditEntry::new(
334            subject.clone(),
335            action.clone(),
336            resource.to_string(),
337            allowed,
338            if allowed {
339                None
340            } else {
341                Some(format!("role {role:?} does not allow {action:?}"))
342            },
343        ));
344        if self.audit_log.len() > self.max_audit_entries {
345            self.audit_log
346                .drain(0..self.audit_log.len() - self.max_audit_entries);
347        }
348        allowed
349    }
350
351    /// Creates a new approval request for a high-risk action.
352    pub fn request_approval(
353        &mut self,
354        subject: Subject,
355        action: Action,
356        resource: String,
357        reason: String,
358    ) -> uuid::Uuid {
359        let id = uuid::Uuid::new_v4();
360        self.pending_approvals.push((
361            PendingApproval {
362                id,
363                subject,
364                action,
365                resource,
366                reason,
367                created_at: Utc::now(),
368            },
369            ApprovalStatus::Pending,
370        ));
371        id
372    }
373
374    /// Approves a pending approval request.
375    pub fn approve(&mut self, id: uuid::Uuid) -> bool {
376        if let Some((_, s)) = self
377            .pending_approvals
378            .iter_mut()
379            .find(|(p, s)| p.id == id && *s == ApprovalStatus::Pending)
380        {
381            *s = ApprovalStatus::Approved;
382            return true;
383        }
384        false
385    }
386
387    /// Rejects a pending approval request.
388    pub fn reject(&mut self, id: uuid::Uuid) -> bool {
389        if let Some((_, s)) = self
390            .pending_approvals
391            .iter_mut()
392            .find(|(p, s)| p.id == id && *s == ApprovalStatus::Pending)
393        {
394            *s = ApprovalStatus::Rejected;
395            return true;
396        }
397        false
398    }
399
400    /// Returns all currently pending approval requests.
401    pub fn pending_approvals(&self) -> Vec<&PendingApproval> {
402        self.pending_approvals
403            .iter()
404            .filter(|(_, s)| matches!(s, ApprovalStatus::Pending))
405            .map(|(p, _)| p)
406            .collect()
407    }
408
409    /// Returns all approval requests (pending + history) with their status.
410    pub fn all_approvals(&self) -> &[(PendingApproval, ApprovalStatus)] {
411        &self.pending_approvals
412    }
413
414    /// Returns the RBAC audit log.
415    pub fn audit_log(&self) -> &[RbacAuditEntry] {
416        &self.audit_log
417    }
418}
419
420impl Default for RbacManager {
421    fn default() -> Self {
422        Self::new()
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_default_policies_exist() {
432        let mgr = RbacManager::new();
433        assert!(mgr.policies.contains_key(&Role::User));
434        assert!(mgr.policies.contains_key(&Role::Superuser));
435        assert!(mgr.policies.contains_key(&Role::Admin));
436    }
437
438    #[test]
439    fn test_role_assignment() {
440        let mut mgr = RbacManager::new();
441        let subject = Subject::User("alice".into());
442        mgr.assign_role(subject.clone(), Role::Admin);
443        assert_eq!(mgr.get_role(&subject), Some(Role::Admin));
444
445        mgr.revoke_role(&subject);
446        assert_eq!(mgr.get_role(&subject), None);
447    }
448
449    #[test]
450    fn test_system_bypasses_rbac() {
451        let mut mgr = RbacManager::new();
452        let subject = Subject::System;
453        assert!(mgr.check_permission(&subject, &Action::ManageRBAC, "test"));
454    }
455
456    #[test]
457    fn test_unknown_subject_denied() {
458        let mut mgr = RbacManager::new();
459        let subject = Subject::User("nobody".into());
460        assert!(!mgr.check_permission(&subject, &Action::UseTool("read".into()), "test"));
461    }
462
463    #[test]
464    fn test_user_allowed_specific_tools() {
465        let mut mgr = RbacManager::new();
466        let subject = Subject::User("bob".into());
467        mgr.assign_role(subject.clone(), Role::User);
468
469        assert!(mgr.check_permission(&subject, &Action::UseTool("read".into()), "test"));
470        assert!(mgr.check_permission(&subject, &Action::UseTool("write".into()), "test"));
471        assert!(mgr.check_permission(&subject, &Action::UseTool("bash".into()), "test"));
472    }
473
474    #[test]
475    fn test_user_denied_admin_tools() {
476        let mut mgr = RbacManager::new();
477        let subject = Subject::User("bob".into());
478        mgr.assign_role(subject.clone(), Role::User);
479
480        assert!(!mgr.check_permission(&subject, &Action::ManageRBAC, "test"));
481        assert!(!mgr.check_permission(&subject, &Action::SystemConfig, "test"));
482    }
483
484    #[test]
485    fn test_admin_wildcard_allows_all_tools() {
486        let mut mgr = RbacManager::new();
487        let subject = Subject::User("admin".into());
488        mgr.assign_role(subject.clone(), Role::Admin);
489
490        // Admin should be able to use ANY tool via wildcard.
491        assert!(mgr.check_permission(&subject, &Action::UseTool("any_tool".into()), "test"));
492        assert!(mgr.check_permission(&subject, &Action::UseTool("custom_thing".into()), "test"));
493        assert!(mgr.check_permission(&subject, &Action::UseTool("dangerous".into()), "test"));
494    }
495
496    #[test]
497    fn test_superuser_wildcard_allows_all_tools() {
498        let mut mgr = RbacManager::new();
499        let subject = Subject::User("super".into());
500        mgr.assign_role(subject.clone(), Role::Superuser);
501
502        assert!(mgr.check_permission(&subject, &Action::UseTool("custom".into()), "test"));
503        assert!(mgr.check_permission(&subject, &Action::UseTool("anything".into()), "test"));
504    }
505
506    #[test]
507    fn test_admin_all_paths_wildcard() {
508        let mut mgr = RbacManager::new();
509        let subject = Subject::User("admin".into());
510        mgr.assign_role(subject.clone(), Role::Admin);
511
512        assert!(mgr.check_permission(&subject, &Action::AccessPath("/any/path".into()), "test"));
513        assert!(mgr.check_permission(&subject, &Action::AccessPath("/secret/data".into()), "test"));
514    }
515
516    #[test]
517    fn test_policy_allows_exact_match() {
518        let policy = Role::User.default_policy();
519        assert!(policy.allows(&Action::UseTool("read".into())));
520        assert!(policy.allows(&Action::UseTool("bash".into())));
521        assert!(!policy.allows(&Action::UseTool("unknown_tool".into())));
522    }
523
524    #[test]
525    fn test_policy_allows_wildcard() {
526        let policy = Role::Admin.default_policy();
527        assert!(policy.allows(&Action::UseTool("literally_anything".into())));
528        assert!(policy.allows(&Action::AccessPath("/some/random/path".into())));
529    }
530
531    #[test]
532    fn test_approval_request_lifecycle() {
533        let mut mgr = RbacManager::new();
534        let id = mgr.request_approval(
535            Subject::User("alice".into()),
536            Action::ManageRBAC,
537            "rbac".into(),
538            "need admin".into(),
539        );
540
541        let pending = mgr.pending_approvals();
542        assert_eq!(pending.len(), 1);
543        assert_eq!(pending[0].id, id);
544
545        assert!(mgr.approve(id));
546        assert!(mgr.pending_approvals().is_empty());
547
548        // Already approved
549        assert!(!mgr.approve(id));
550    }
551
552    #[test]
553    fn test_approval_rejection() {
554        let mut mgr = RbacManager::new();
555        let id = mgr.request_approval(
556            Subject::User("alice".into()),
557            Action::SystemConfig,
558            "config".into(),
559            "need config".into(),
560        );
561
562        assert!(mgr.reject(id));
563        assert!(mgr.pending_approvals().is_empty());
564    }
565
566    #[test]
567    fn test_approval_nonexistent() {
568        let mut mgr = RbacManager::new();
569        assert!(!mgr.approve(uuid::Uuid::new_v4()));
570        assert!(!mgr.reject(uuid::Uuid::new_v4()));
571    }
572
573    #[test]
574    fn test_audit_log_recorded() {
575        let mut mgr = RbacManager::new();
576        let subject = Subject::User("alice".into());
577        mgr.assign_role(subject.clone(), Role::User);
578
579        mgr.check_permission(&subject, &Action::UseTool("read".into()), "test");
580        assert!(!mgr.audit_log().is_empty());
581
582        let entry = &mgr.audit_log()[0];
583        assert!(entry.allowed);
584    }
585
586    #[test]
587    fn test_audit_log_denied_recorded() {
588        let mut mgr = RbacManager::new();
589        let subject = Subject::User("alice".into());
590        mgr.assign_role(subject.clone(), Role::User);
591
592        mgr.check_permission(&subject, &Action::ManageRBAC, "test");
593        let denied_entries: Vec<_> = mgr.audit_log().iter().filter(|e| !e.allowed).collect();
594        assert_eq!(denied_entries.len(), 1);
595    }
596}