Skip to main content

typesec_rbac/
engine.rs

1//! RBAC policy engine — implements [`PolicyEngine`] for [`RbacPolicy`].
2
3use std::collections::{HashMap, HashSet};
4
5use glob::Pattern;
6use tracing::debug;
7use typesec_core::policy::{PolicyEngine, PolicyResult};
8
9use crate::model::RbacPolicy;
10
11/// A compiled, fast-lookup RBAC engine.
12///
13/// After construction from an [`RbacPolicy`], the engine pre-computes:
14/// - Effective permissions per role (with inheritance flattened).
15/// - Subject → role mappings.
16///
17/// Every `check()` call does O(roles × patterns) work — fast enough for
18/// the sizes of policies used in AI agent deployments.
19pub struct RbacEngine {
20    /// Subject → set of effective (permission, resource_pattern) pairs.
21    subject_grants: HashMap<String, Vec<Grant>>,
22}
23
24#[derive(Debug, Clone)]
25struct Grant {
26    permission: String,
27    resource_patterns: Vec<String>,
28}
29
30impl RbacEngine {
31    /// Build an engine from a validated [`RbacPolicy`].
32    ///
33    /// Returns an error if the policy fails validation.
34    pub fn new(policy: RbacPolicy) -> Result<Self, String> {
35        policy.validate()?;
36
37        // Step 1: flatten role inheritance into effective (permission, resources) pairs.
38        let effective_roles: HashMap<String, Vec<Grant>> = {
39            let mut map = HashMap::new();
40            for role in &policy.roles {
41                let grants = flatten_role(&role.name, &policy);
42                map.insert(role.name.clone(), grants);
43            }
44            map
45        };
46
47        // Step 2: build subject → grants mapping.
48        let mut subject_grants: HashMap<String, Vec<Grant>> = HashMap::new();
49        for assignment in &policy.assignments {
50            let mut all_grants: Vec<Grant> = Vec::new();
51            for role_name in &assignment.roles {
52                if let Some(grants) = effective_roles.get(role_name) {
53                    all_grants.extend(grants.iter().cloned());
54                }
55            }
56            subject_grants
57                .entry(assignment.subject.clone())
58                .or_default()
59                .extend(all_grants);
60        }
61
62        Ok(Self { subject_grants })
63    }
64
65    /// Load an engine directly from a YAML string.
66    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
67        let policy = RbacPolicy::from_yaml(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
68        Self::new(policy)
69    }
70}
71
72impl PolicyEngine for RbacEngine {
73    fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
74        debug!(subject, action, resource, "rbac check");
75
76        let grants = match self.subject_grants.get(subject) {
77            Some(g) => g,
78            None => {
79                return PolicyResult::Deny(format!("no role assignments for subject '{subject}'"));
80            }
81        };
82
83        for grant in grants {
84            if grant.permission == action {
85                for pattern in &grant.resource_patterns {
86                    if matches_glob(pattern, resource) {
87                        return PolicyResult::Allow;
88                    }
89                }
90            }
91        }
92
93        PolicyResult::Deny(format!(
94            "no rule grants '{subject}' permission '{action}' on '{resource}'"
95        ))
96    }
97}
98
99/// Recursively flatten a role's permissions by resolving inheritance.
100fn flatten_role(role_name: &str, policy: &RbacPolicy) -> Vec<Grant> {
101    let mut seen = HashSet::new();
102    flatten_role_inner(role_name, policy, &mut seen)
103}
104
105fn flatten_role_inner(
106    role_name: &str,
107    policy: &RbacPolicy,
108    seen: &mut HashSet<String>,
109) -> Vec<Grant> {
110    if !seen.insert(role_name.to_owned()) {
111        return vec![]; // cycle guard (already validated, but be safe)
112    }
113
114    let role = match policy.roles.iter().find(|r| r.name == role_name) {
115        Some(r) => r,
116        None => return vec![],
117    };
118
119    let mut grants: Vec<Grant> = Vec::new();
120
121    // Own permissions.
122    for perm in &role.permissions {
123        grants.push(Grant {
124            permission: perm.clone(),
125            resource_patterns: role.resources.clone(),
126        });
127    }
128
129    // Inherited permissions (recursive).
130    for parent_name in &role.inherits {
131        let inherited = flatten_role_inner(parent_name, policy, seen);
132        grants.extend(inherited);
133    }
134
135    grants
136}
137
138/// Match a resource string against a glob pattern.
139/// Uses the `glob` crate — patterns like `"reports/*"` or `"*"`.
140fn matches_glob(pattern: &str, resource: &str) -> bool {
141    if pattern == "*" {
142        return true;
143    }
144    Pattern::new(pattern).is_ok_and(|p| p.matches(resource))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    const YAML: &str = r#"
152roles:
153  - name: analyst
154    permissions: [read, read_sensitive]
155    resources: ["reports/*", "metrics/*"]
156  - name: engineer
157    permissions: [read, write, execute]
158    resources: ["code/*", "infra/*"]
159  - name: admin
160    inherits: [analyst, engineer]
161    permissions: [delete, delegate]
162    resources: ["*"]
163
164assignments:
165  - subject: "agent:data-pipeline"
166    roles: [analyst]
167  - subject: "agent:deploy-bot"
168    roles: [engineer]
169  - subject: "agent:superuser"
170    roles: [admin]
171"#;
172
173    fn engine() -> RbacEngine {
174        RbacEngine::from_yaml(YAML).expect("engine build should succeed")
175    }
176
177    #[test]
178    fn analyst_can_read_reports() {
179        let e = engine();
180        assert_eq!(
181            e.check("agent:data-pipeline", "read", "reports/q1"),
182            PolicyResult::Allow
183        );
184    }
185
186    #[test]
187    fn analyst_cannot_write() {
188        let e = engine();
189        assert!(matches!(
190            e.check("agent:data-pipeline", "write", "reports/q1"),
191            PolicyResult::Deny(_)
192        ));
193    }
194
195    #[test]
196    fn engineer_can_write_code() {
197        let e = engine();
198        assert_eq!(
199            e.check("agent:deploy-bot", "write", "code/main.rs"),
200            PolicyResult::Allow
201        );
202    }
203
204    #[test]
205    fn engineer_cannot_access_reports() {
206        let e = engine();
207        assert!(matches!(
208            e.check("agent:deploy-bot", "read", "reports/q1"),
209            PolicyResult::Deny(_)
210        ));
211    }
212
213    #[test]
214    fn admin_inherits_analyst_and_engineer() {
215        let e = engine();
216        // Inherited from analyst:
217        assert_eq!(
218            e.check("agent:superuser", "read_sensitive", "reports/q1"),
219            PolicyResult::Allow
220        );
221        // Inherited from engineer:
222        assert_eq!(
223            e.check("agent:superuser", "execute", "code/deploy.sh"),
224            PolicyResult::Allow
225        );
226        // Own permissions:
227        assert_eq!(
228            e.check("agent:superuser", "delete", "anything"),
229            PolicyResult::Allow
230        );
231    }
232
233    #[test]
234    fn unknown_subject_is_denied() {
235        let e = engine();
236        assert!(matches!(
237            e.check("agent:ghost", "read", "reports/q1"),
238            PolicyResult::Deny(_)
239        ));
240    }
241}