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::{
8    ResourceId, SubjectId,
9    policy::{PolicyEngine, PolicyResult},
10};
11
12use crate::model::RbacPolicy;
13
14/// A compiled, fast-lookup RBAC engine.
15///
16/// After construction from an [`RbacPolicy`], the engine pre-computes:
17/// - Effective permissions per role (with inheritance flattened).
18/// - Subject → role mappings.
19///
20/// Every `check()` call does O(roles × patterns) work — fast enough for
21/// the sizes of policies used in AI agent deployments.
22pub struct RbacEngine {
23    /// Subject → set of effective (permission, resource_pattern) pairs.
24    subject_grants: HashMap<String, Vec<CompiledGrant>>,
25    /// Glob subject pattern → set of effective grants.
26    wildcard_subject_grants: Vec<(SubjectPattern, Vec<CompiledGrant>)>,
27}
28
29#[derive(Debug, Clone)]
30struct Grant {
31    permission: String,
32    resource_patterns: Vec<String>,
33}
34
35/// A grant with its glob patterns validated and compiled once at load time.
36///
37/// Compiling here (rather than per `check()`) both surfaces pattern typos as
38/// load errors — a malformed pattern would otherwise silently never match,
39/// i.e. silently deny — and avoids re-parsing the glob on every check.
40#[derive(Debug, Clone)]
41struct CompiledGrant {
42    permission: String,
43    resource_patterns: Vec<ResourcePattern>,
44}
45
46#[derive(Debug, Clone)]
47enum ResourcePattern {
48    /// The literal `"*"` — matches every resource, including across `/`.
49    Any,
50    /// A compiled glob. Note glob `*` does not cross `/` separators:
51    /// `reports/*` matches `reports/q1` but not `reports/2024/q1` (use
52    /// `reports/**` for that).
53    Glob(Pattern),
54}
55
56#[derive(Debug, Clone)]
57enum SubjectPattern {
58    /// The literal `"*"` — matches every subject.
59    Any,
60    /// A compiled glob.
61    Glob(Pattern),
62}
63
64impl SubjectPattern {
65    fn compile(pattern: &str) -> Result<Self, String> {
66        if pattern == "*" {
67            return Ok(Self::Any);
68        }
69        Pattern::new(pattern)
70            .map(Self::Glob)
71            .map_err(|e| format!("invalid subject pattern '{pattern}': {e}"))
72    }
73
74    fn matches(&self, subject: &str) -> bool {
75        match self {
76            Self::Any => true,
77            Self::Glob(pattern) => pattern.matches(subject),
78        }
79    }
80}
81
82impl ResourcePattern {
83    fn compile(pattern: &str) -> Result<Self, String> {
84        if pattern == "*" {
85            return Ok(Self::Any);
86        }
87        Pattern::new(pattern)
88            .map(Self::Glob)
89            .map_err(|e| format!("invalid resource pattern '{pattern}': {e}"))
90    }
91
92    fn matches(&self, resource: &str) -> bool {
93        match self {
94            Self::Any => true,
95            Self::Glob(pattern) => pattern.matches(resource),
96        }
97    }
98}
99
100impl RbacEngine {
101    /// Build an engine from a validated [`RbacPolicy`].
102    ///
103    /// Returns an error if the policy fails validation.
104    pub fn new(policy: RbacPolicy) -> Result<Self, String> {
105        policy.validate()?;
106
107        // Step 1: flatten role inheritance into effective (permission, resources) pairs.
108        let effective_roles: HashMap<String, Vec<Grant>> = {
109            let mut map = HashMap::new();
110            for role in &policy.roles {
111                let grants = flatten_role(&role.name, &policy);
112                map.insert(role.name.clone(), grants);
113            }
114            map
115        };
116
117        // Step 2: build subject → grants mapping, compiling patterns up front
118        // so invalid globs fail the policy load instead of silently denying.
119        let mut subject_grants: HashMap<String, Vec<CompiledGrant>> = HashMap::new();
120        let mut wildcard_subject_grants: Vec<(SubjectPattern, Vec<CompiledGrant>)> = Vec::new();
121        for assignment in &policy.assignments {
122            let mut all_grants: Vec<CompiledGrant> = Vec::new();
123            for role_name in &assignment.roles {
124                if let Some(grants) = effective_roles.get(role_name) {
125                    for grant in grants {
126                        all_grants.push(CompiledGrant {
127                            permission: grant.permission.clone(),
128                            resource_patterns: grant
129                                .resource_patterns
130                                .iter()
131                                .map(|p| ResourcePattern::compile(p))
132                                .collect::<Result<_, _>>()?,
133                        });
134                    }
135                }
136            }
137            if is_glob_pattern(&assignment.subject) {
138                wildcard_subject_grants
139                    .push((SubjectPattern::compile(&assignment.subject)?, all_grants));
140            } else {
141                subject_grants
142                    .entry(assignment.subject.clone())
143                    .or_default()
144                    .extend(all_grants);
145            }
146        }
147
148        Ok(Self {
149            subject_grants,
150            wildcard_subject_grants,
151        })
152    }
153
154    /// Load an engine directly from a YAML string.
155    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
156        let policy = RbacPolicy::from_yaml(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
157        Self::new(policy)
158    }
159}
160
161impl PolicyEngine for RbacEngine {
162    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
163        let subject = subject.as_str();
164        let resource = resource.as_str();
165        debug!(subject, action, resource, "rbac check");
166
167        let exact_grants = self.subject_grants.get(subject).into_iter().flatten();
168        let wildcard_grants = self
169            .wildcard_subject_grants
170            .iter()
171            .filter(|(pattern, _)| pattern.matches(subject))
172            .flat_map(|(_, grants)| grants);
173
174        let mut matched_subject = false;
175        for grant in exact_grants.chain(wildcard_grants) {
176            matched_subject = true;
177            if grant.permission == action {
178                for pattern in &grant.resource_patterns {
179                    if pattern.matches(resource) {
180                        return PolicyResult::Allow;
181                    }
182                }
183            }
184        }
185
186        if !matched_subject {
187            return PolicyResult::Deny(format!("no role assignments for subject '{subject}'"));
188        }
189
190        PolicyResult::Deny(format!(
191            "no rule grants '{subject}' permission '{action}' on '{resource}'"
192        ))
193    }
194}
195
196fn is_glob_pattern(value: &str) -> bool {
197    value.contains(['*', '?', '['])
198}
199
200/// Recursively flatten a role's permissions by resolving inheritance.
201fn flatten_role(role_name: &str, policy: &RbacPolicy) -> Vec<Grant> {
202    let mut seen = HashSet::new();
203    flatten_role_inner(role_name, policy, &mut seen)
204}
205
206fn flatten_role_inner(
207    role_name: &str,
208    policy: &RbacPolicy,
209    seen: &mut HashSet<String>,
210) -> Vec<Grant> {
211    if !seen.insert(role_name.to_owned()) {
212        return vec![]; // cycle guard (already validated, but be safe)
213    }
214
215    let role = match policy.roles.iter().find(|r| r.name == role_name) {
216        Some(r) => r,
217        None => return vec![],
218    };
219
220    let mut grants: Vec<Grant> = Vec::new();
221
222    // Own permissions.
223    for perm in &role.permissions {
224        grants.push(Grant {
225            permission: perm.clone(),
226            resource_patterns: role.resources.clone(),
227        });
228    }
229
230    // Inherited permissions (recursive).
231    for parent_name in &role.inherits {
232        let inherited = flatten_role_inner(parent_name, policy, seen);
233        grants.extend(inherited);
234    }
235
236    grants
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    const YAML: &str = r#"
244roles:
245  - name: analyst
246    permissions: [read, read_sensitive]
247    resources: ["reports/*", "metrics/*"]
248  - name: engineer
249    permissions: [read, write, execute]
250    resources: ["code/*", "infra/*"]
251  - name: admin
252    inherits: [analyst, engineer]
253    permissions: [delete, delegate]
254    resources: ["*"]
255
256assignments:
257  - subject: "agent:data-pipeline"
258    roles: [analyst]
259  - subject: "agent:deploy-bot"
260    roles: [engineer]
261  - subject: "agent:superuser"
262    roles: [admin]
263"#;
264
265    fn engine() -> RbacEngine {
266        RbacEngine::from_yaml(YAML).expect("engine build should succeed")
267    }
268
269    fn check(e: &RbacEngine, subject: &str, action: &str, resource: &str) -> PolicyResult {
270        e.check(
271            &SubjectId::from(subject),
272            action,
273            &ResourceId::from(resource),
274        )
275    }
276
277    #[test]
278    fn analyst_can_read_reports() {
279        let e = engine();
280        assert_eq!(
281            check(&e, "agent:data-pipeline", "read", "reports/q1"),
282            PolicyResult::Allow
283        );
284    }
285
286    #[test]
287    fn analyst_cannot_write() {
288        let e = engine();
289        assert!(matches!(
290            check(&e, "agent:data-pipeline", "write", "reports/q1"),
291            PolicyResult::Deny(_)
292        ));
293    }
294
295    #[test]
296    fn engineer_can_write_code() {
297        let e = engine();
298        assert_eq!(
299            check(&e, "agent:deploy-bot", "write", "code/main.rs"),
300            PolicyResult::Allow
301        );
302    }
303
304    #[test]
305    fn engineer_cannot_access_reports() {
306        let e = engine();
307        assert!(matches!(
308            check(&e, "agent:deploy-bot", "read", "reports/q1"),
309            PolicyResult::Deny(_)
310        ));
311    }
312
313    #[test]
314    fn admin_inherits_analyst_and_engineer() {
315        let e = engine();
316        // Inherited from analyst:
317        assert_eq!(
318            check(&e, "agent:superuser", "read_sensitive", "reports/q1"),
319            PolicyResult::Allow
320        );
321        // Inherited from engineer:
322        assert_eq!(
323            check(&e, "agent:superuser", "execute", "code/deploy.sh"),
324            PolicyResult::Allow
325        );
326        // Own permissions:
327        assert_eq!(
328            check(&e, "agent:superuser", "delete", "anything"),
329            PolicyResult::Allow
330        );
331    }
332
333    #[test]
334    fn invalid_resource_pattern_fails_policy_load() {
335        let yaml = r#"
336roles:
337  - name: broken
338    permissions: [read]
339    resources: ["reports/[unclosed"]
340
341assignments:
342  - subject: "agent:x"
343    roles: [broken]
344"#;
345        let result = RbacEngine::from_yaml(yaml);
346        assert!(
347            result.is_err(),
348            "malformed glob must fail at load, not silently deny"
349        );
350    }
351
352    #[test]
353    fn unknown_subject_is_denied() {
354        let e = engine();
355        assert!(matches!(
356            check(&e, "agent:ghost", "read", "reports/q1"),
357            PolicyResult::Deny(_)
358        ));
359    }
360
361    #[test]
362    fn wildcard_subject_assignment_matches_globbed_agents() {
363        let yaml = r#"
364roles:
365  - name: deployer
366    permissions: [execute]
367    resources: ["infra/*"]
368
369assignments:
370  - subject: "agent:deploy-*"
371    roles: [deployer]
372"#;
373        let e = RbacEngine::from_yaml(yaml).expect("engine build should succeed");
374        assert_eq!(
375            check(&e, "agent:deploy-prod", "execute", "infra/restart"),
376            PolicyResult::Allow
377        );
378        assert!(matches!(
379            check(&e, "agent:build-prod", "execute", "infra/restart"),
380            PolicyResult::Deny(_)
381        ));
382    }
383
384    #[test]
385    fn exact_subject_and_wildcard_subject_grants_are_combined() {
386        let yaml = r#"
387roles:
388  - name: reader
389    permissions: [read]
390    resources: ["reports/*"]
391  - name: writer
392    permissions: [write]
393    resources: ["reports/*"]
394
395assignments:
396  - subject: "agent:report-*"
397    roles: [reader]
398  - subject: "agent:report-prod"
399    roles: [writer]
400"#;
401        let e = RbacEngine::from_yaml(yaml).expect("engine build should succeed");
402        assert_eq!(
403            check(&e, "agent:report-prod", "read", "reports/q1"),
404            PolicyResult::Allow
405        );
406        assert_eq!(
407            check(&e, "agent:report-prod", "write", "reports/q1"),
408            PolicyResult::Allow
409        );
410    }
411
412    #[test]
413    fn invalid_subject_pattern_fails_policy_load() {
414        let yaml = r#"
415roles:
416  - name: reader
417    permissions: [read]
418    resources: ["*"]
419
420assignments:
421  - subject: "agent:[broken"
422    roles: [reader]
423"#;
424        let result = RbacEngine::from_yaml(yaml);
425        assert!(
426            result.is_err(),
427            "malformed subject glob must fail at load, not silently deny"
428        );
429    }
430
431    #[test]
432    fn cyclic_role_inheritance_fails_engine_construction() {
433        let yaml = r#"
434roles:
435  - name: a
436    inherits: [b]
437    permissions: [read]
438    resources: ["*"]
439  - name: b
440    inherits: [a]
441    permissions: [write]
442    resources: ["*"]
443
444assignments:
445  - subject: "agent:x"
446    roles: [a]
447"#;
448
449        assert!(RbacEngine::from_yaml(yaml).is_err());
450    }
451}