1use std::collections::{HashMap, HashSet};
4
5use glob::Pattern;
6use tracing::debug;
7use typesec_core::policy::{PolicyEngine, PolicyResult};
8
9use crate::model::RbacPolicy;
10
11pub struct RbacEngine {
20 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#[derive(Debug, Clone)]
36struct CompiledGrant {
37 permission: String,
38 resource_patterns: Vec<ResourcePattern>,
39}
40
41#[derive(Debug, Clone)]
42enum ResourcePattern {
43 Any,
45 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 pub fn new(policy: RbacPolicy) -> Result<Self, String> {
74 policy.validate()?;
75
76 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 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 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
148fn 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![]; }
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 for perm in &role.permissions {
172 grants.push(Grant {
173 permission: perm.clone(),
174 resource_patterns: role.resources.clone(),
175 });
176 }
177
178 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 assert_eq!(
258 e.check("agent:superuser", "read_sensitive", "reports/q1"),
259 PolicyResult::Allow
260 );
261 assert_eq!(
263 e.check("agent:superuser", "execute", "code/deploy.sh"),
264 PolicyResult::Allow
265 );
266 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}