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<CompiledGrant>>,
22}
23
24#[derive(Debug, Clone)]
25struct Grant {
26    permission: String,
27    resource_patterns: Vec<String>,
28}
29
30/// A grant with its glob patterns validated and compiled once at load time.
31///
32/// Compiling here (rather than per `check()`) both surfaces pattern typos as
33/// load errors — a malformed pattern would otherwise silently never match,
34/// i.e. silently deny — and avoids re-parsing the glob on every check.
35#[derive(Debug, Clone)]
36struct CompiledGrant {
37    permission: String,
38    resource_patterns: Vec<ResourcePattern>,
39}
40
41#[derive(Debug, Clone)]
42enum ResourcePattern {
43    /// The literal `"*"` — matches every resource, including across `/`.
44    Any,
45    /// A compiled glob. Note glob `*` does not cross `/` separators:
46    /// `reports/*` matches `reports/q1` but not `reports/2024/q1` (use
47    /// `reports/**` for that).
48    Glob(Pattern),
49}
50
51impl ResourcePattern {
52    fn compile(pattern: &str) -> Result<Self, String> {
53        if pattern == "*" {
54            return Ok(Self::Any);
55        }
56        Pattern::new(pattern)
57            .map(Self::Glob)
58            .map_err(|e| format!("invalid resource pattern '{pattern}': {e}"))
59    }
60
61    fn matches(&self, resource: &str) -> bool {
62        match self {
63            Self::Any => true,
64            Self::Glob(pattern) => pattern.matches(resource),
65        }
66    }
67}
68
69impl RbacEngine {
70    /// Build an engine from a validated [`RbacPolicy`].
71    ///
72    /// Returns an error if the policy fails validation.
73    pub fn new(policy: RbacPolicy) -> Result<Self, String> {
74        policy.validate()?;
75
76        // Step 1: flatten role inheritance into effective (permission, resources) pairs.
77        let effective_roles: HashMap<String, Vec<Grant>> = {
78            let mut map = HashMap::new();
79            for role in &policy.roles {
80                let grants = flatten_role(&role.name, &policy);
81                map.insert(role.name.clone(), grants);
82            }
83            map
84        };
85
86        // Step 2: build subject → grants mapping, compiling patterns up front
87        // so invalid globs fail the policy load instead of silently denying.
88        let mut subject_grants: HashMap<String, Vec<CompiledGrant>> = HashMap::new();
89        for assignment in &policy.assignments {
90            let mut all_grants: Vec<CompiledGrant> = Vec::new();
91            for role_name in &assignment.roles {
92                if let Some(grants) = effective_roles.get(role_name) {
93                    for grant in grants {
94                        all_grants.push(CompiledGrant {
95                            permission: grant.permission.clone(),
96                            resource_patterns: grant
97                                .resource_patterns
98                                .iter()
99                                .map(|p| ResourcePattern::compile(p))
100                                .collect::<Result<_, _>>()?,
101                        });
102                    }
103                }
104            }
105            subject_grants
106                .entry(assignment.subject.clone())
107                .or_default()
108                .extend(all_grants);
109        }
110
111        Ok(Self { subject_grants })
112    }
113
114    /// Load an engine directly from a YAML string.
115    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
116        let policy = RbacPolicy::from_yaml(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
117        Self::new(policy)
118    }
119}
120
121impl PolicyEngine for RbacEngine {
122    fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
123        debug!(subject, action, resource, "rbac check");
124
125        let grants = match self.subject_grants.get(subject) {
126            Some(g) => g,
127            None => {
128                return PolicyResult::Deny(format!("no role assignments for subject '{subject}'"));
129            }
130        };
131
132        for grant in grants {
133            if grant.permission == action {
134                for pattern in &grant.resource_patterns {
135                    if pattern.matches(resource) {
136                        return PolicyResult::Allow;
137                    }
138                }
139            }
140        }
141
142        PolicyResult::Deny(format!(
143            "no rule grants '{subject}' permission '{action}' on '{resource}'"
144        ))
145    }
146}
147
148/// Recursively flatten a role's permissions by resolving inheritance.
149fn flatten_role(role_name: &str, policy: &RbacPolicy) -> Vec<Grant> {
150    let mut seen = HashSet::new();
151    flatten_role_inner(role_name, policy, &mut seen)
152}
153
154fn flatten_role_inner(
155    role_name: &str,
156    policy: &RbacPolicy,
157    seen: &mut HashSet<String>,
158) -> Vec<Grant> {
159    if !seen.insert(role_name.to_owned()) {
160        return vec![]; // cycle guard (already validated, but be safe)
161    }
162
163    let role = match policy.roles.iter().find(|r| r.name == role_name) {
164        Some(r) => r,
165        None => return vec![],
166    };
167
168    let mut grants: Vec<Grant> = Vec::new();
169
170    // Own permissions.
171    for perm in &role.permissions {
172        grants.push(Grant {
173            permission: perm.clone(),
174            resource_patterns: role.resources.clone(),
175        });
176    }
177
178    // Inherited permissions (recursive).
179    for parent_name in &role.inherits {
180        let inherited = flatten_role_inner(parent_name, policy, seen);
181        grants.extend(inherited);
182    }
183
184    grants
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    const YAML: &str = r#"
192roles:
193  - name: analyst
194    permissions: [read, read_sensitive]
195    resources: ["reports/*", "metrics/*"]
196  - name: engineer
197    permissions: [read, write, execute]
198    resources: ["code/*", "infra/*"]
199  - name: admin
200    inherits: [analyst, engineer]
201    permissions: [delete, delegate]
202    resources: ["*"]
203
204assignments:
205  - subject: "agent:data-pipeline"
206    roles: [analyst]
207  - subject: "agent:deploy-bot"
208    roles: [engineer]
209  - subject: "agent:superuser"
210    roles: [admin]
211"#;
212
213    fn engine() -> RbacEngine {
214        RbacEngine::from_yaml(YAML).expect("engine build should succeed")
215    }
216
217    #[test]
218    fn analyst_can_read_reports() {
219        let e = engine();
220        assert_eq!(
221            e.check("agent:data-pipeline", "read", "reports/q1"),
222            PolicyResult::Allow
223        );
224    }
225
226    #[test]
227    fn analyst_cannot_write() {
228        let e = engine();
229        assert!(matches!(
230            e.check("agent:data-pipeline", "write", "reports/q1"),
231            PolicyResult::Deny(_)
232        ));
233    }
234
235    #[test]
236    fn engineer_can_write_code() {
237        let e = engine();
238        assert_eq!(
239            e.check("agent:deploy-bot", "write", "code/main.rs"),
240            PolicyResult::Allow
241        );
242    }
243
244    #[test]
245    fn engineer_cannot_access_reports() {
246        let e = engine();
247        assert!(matches!(
248            e.check("agent:deploy-bot", "read", "reports/q1"),
249            PolicyResult::Deny(_)
250        ));
251    }
252
253    #[test]
254    fn admin_inherits_analyst_and_engineer() {
255        let e = engine();
256        // Inherited from analyst:
257        assert_eq!(
258            e.check("agent:superuser", "read_sensitive", "reports/q1"),
259            PolicyResult::Allow
260        );
261        // Inherited from engineer:
262        assert_eq!(
263            e.check("agent:superuser", "execute", "code/deploy.sh"),
264            PolicyResult::Allow
265        );
266        // Own permissions:
267        assert_eq!(
268            e.check("agent:superuser", "delete", "anything"),
269            PolicyResult::Allow
270        );
271    }
272
273    #[test]
274    fn invalid_resource_pattern_fails_policy_load() {
275        let yaml = r#"
276roles:
277  - name: broken
278    permissions: [read]
279    resources: ["reports/[unclosed"]
280
281assignments:
282  - subject: "agent:x"
283    roles: [broken]
284"#;
285        let result = RbacEngine::from_yaml(yaml);
286        assert!(
287            result.is_err(),
288            "malformed glob must fail at load, not silently deny"
289        );
290    }
291
292    #[test]
293    fn unknown_subject_is_denied() {
294        let e = engine();
295        assert!(matches!(
296            e.check("agent:ghost", "read", "reports/q1"),
297            PolicyResult::Deny(_)
298        ));
299    }
300}