Skip to main content

typesec_rbac/
engine.rs

1//! RBAC policy engine — implements [`PolicyEngine`] for [`RbacPolicy`].
2
3mod flatten;
4mod pattern;
5
6use std::collections::HashMap;
7
8use tracing::debug;
9use typesec_core::{
10    ResourceId, SubjectId,
11    policy::{PolicyEngine, PolicyResult},
12};
13
14use crate::model::RbacPolicy;
15use flatten::flatten_role;
16use pattern::{GlobPattern, is_glob_pattern};
17
18/// A compiled, fast-lookup RBAC engine.
19///
20/// After construction from an [`RbacPolicy`], the engine pre-computes:
21/// - Effective permissions per role (with inheritance flattened).
22/// - Subject → role mappings.
23///
24/// Every `check()` call does O(roles × patterns) work — fast enough for
25/// the sizes of policies used in AI agent deployments.
26pub struct RbacEngine {
27    /// Subject → set of effective (permission, resource_pattern) pairs.
28    subject_grants: HashMap<String, Vec<CompiledGrant>>,
29    /// Glob subject pattern → set of effective grants.
30    wildcard_subject_grants: Vec<(GlobPattern, Vec<CompiledGrant>)>,
31}
32
33/// A grant with its glob patterns validated and compiled once at load time.
34///
35/// Compiling here (rather than per `check()`) both surfaces pattern typos as
36/// load errors — a malformed pattern would otherwise silently never match,
37/// i.e. silently deny — and avoids re-parsing the glob on every check.
38#[derive(Debug, Clone)]
39struct CompiledGrant {
40    permission: String,
41    resource_patterns: Vec<GlobPattern>,
42}
43
44impl RbacEngine {
45    /// Build an engine from a validated [`RbacPolicy`].
46    ///
47    /// Returns an error if the policy fails validation.
48    pub fn new(policy: RbacPolicy) -> Result<Self, String> {
49        policy.validate()?;
50
51        // Step 1: flatten role inheritance into effective (permission, resources) pairs.
52        let effective_roles: HashMap<String, Vec<flatten::Grant>> = {
53            let mut map = HashMap::new();
54            for role in &policy.roles {
55                let grants = flatten_role(&role.name, &policy);
56                map.insert(role.name.clone(), grants);
57            }
58            map
59        };
60
61        // Step 2: build subject → grants mapping, compiling patterns up front
62        // so invalid globs fail the policy load instead of silently denying.
63        let mut subject_grants: HashMap<String, Vec<CompiledGrant>> = HashMap::new();
64        let mut wildcard_subject_grants: Vec<(GlobPattern, Vec<CompiledGrant>)> = Vec::new();
65        for assignment in &policy.assignments {
66            let mut all_grants: Vec<CompiledGrant> = Vec::new();
67            for role_name in &assignment.roles {
68                if let Some(grants) = effective_roles.get(role_name) {
69                    for grant in grants {
70                        all_grants.push(CompiledGrant {
71                            permission: grant.permission.clone(),
72                            resource_patterns: grant
73                                .resource_patterns
74                                .iter()
75                                .map(|p| GlobPattern::compile(p, "resource"))
76                                .collect::<Result<_, _>>()?,
77                        });
78                    }
79                }
80            }
81            if is_glob_pattern(&assignment.subject) {
82                wildcard_subject_grants.push((
83                    GlobPattern::compile(&assignment.subject, "subject")?,
84                    all_grants,
85                ));
86            } else {
87                subject_grants
88                    .entry(assignment.subject.clone())
89                    .or_default()
90                    .extend(all_grants);
91            }
92        }
93
94        Ok(Self {
95            subject_grants,
96            wildcard_subject_grants,
97        })
98    }
99
100    /// Load an engine directly from a YAML string.
101    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
102        let policy = RbacPolicy::from_yaml(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
103        Self::new(policy)
104    }
105}
106
107impl PolicyEngine for RbacEngine {
108    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
109        let subject = subject.as_str();
110        let resource = resource.as_str();
111        debug!(subject, action, resource, "rbac check");
112
113        let exact_grants = self.subject_grants.get(subject).into_iter().flatten();
114        let wildcard_grants = self
115            .wildcard_subject_grants
116            .iter()
117            .filter(|(pattern, _)| pattern.matches(subject))
118            .flat_map(|(_, grants)| grants);
119
120        let mut matched_subject = false;
121        for grant in exact_grants.chain(wildcard_grants) {
122            matched_subject = true;
123            if grant.permission == action {
124                for pattern in &grant.resource_patterns {
125                    if pattern.matches(resource) {
126                        return PolicyResult::Allow;
127                    }
128                }
129            }
130        }
131
132        if !matched_subject {
133            return PolicyResult::Deny(format!("no role assignments for subject '{subject}'"));
134        }
135
136        PolicyResult::Deny(format!(
137            "no rule grants '{subject}' permission '{action}' on '{resource}'"
138        ))
139    }
140}
141
142#[cfg(test)]
143mod tests;