1use 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
14pub struct RbacEngine {
23 subject_grants: HashMap<String, Vec<CompiledGrant>>,
25 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#[derive(Debug, Clone)]
41struct CompiledGrant {
42 permission: String,
43 resource_patterns: Vec<ResourcePattern>,
44}
45
46#[derive(Debug, Clone)]
47enum ResourcePattern {
48 Any,
50 Glob(Pattern),
54}
55
56#[derive(Debug, Clone)]
57enum SubjectPattern {
58 Any,
60 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 pub fn new(policy: RbacPolicy) -> Result<Self, String> {
105 policy.validate()?;
106
107 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 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 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
200fn 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![]; }
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 for perm in &role.permissions {
224 grants.push(Grant {
225 permission: perm.clone(),
226 resource_patterns: role.resources.clone(),
227 });
228 }
229
230 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 assert_eq!(
318 check(&e, "agent:superuser", "read_sensitive", "reports/q1"),
319 PolicyResult::Allow
320 );
321 assert_eq!(
323 check(&e, "agent:superuser", "execute", "code/deploy.sh"),
324 PolicyResult::Allow
325 );
326 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}