Skip to main content

typesec_rbac/
engine.rs

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