1use std::collections::HashMap;
4
5use glob::Pattern;
6use tracing::debug;
7use typesec_core::{
8 ResourceId, SubjectId,
9 policy::{PolicyEngine, PolicyResult, RequestContext},
10};
11
12use crate::{
13 audit::{ConstraintEval, OdrlAuditEvent, OdrlVerdict},
14 constraint::{ConstraintContext, evaluate},
15 model::{OdrlDocument, OdrlRuleType, RuleAction},
16};
17
18struct RuleMatch {
19 policy_uid: String,
20 evals: Vec<ConstraintEval>,
21}
22
23type RuleKey = (String, String);
24type RuleIndex = HashMap<RuleKey, Vec<RuleRef>>;
25type WildcardActionIndex = HashMap<String, Vec<RuleRef>>;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28struct RuleRef {
29 policy_index: usize,
30 rule_index: usize,
31 ordinal: usize,
32}
33
34pub struct OdrlEngine {
42 doc: OdrlDocument,
43 exact_rules: RuleIndex,
45 wildcard_action_rules: WildcardActionIndex,
47 default_context: ConstraintContext,
49}
50
51impl OdrlEngine {
52 pub fn new(doc: OdrlDocument) -> Self {
54 let (exact_rules, wildcard_action_rules) = build_rule_index(&doc);
55 Self {
56 doc,
57 exact_rules,
58 wildcard_action_rules,
59 default_context: ConstraintContext::default(),
60 }
61 }
62
63 pub fn from_yaml(yaml: &str) -> Result<Self, String> {
65 let doc =
66 OdrlDocument::from_yaml(yaml).map_err(|e| format!("ODRL YAML parse error: {e}"))?;
67 Ok(Self::new(doc))
68 }
69
70 pub fn with_context(mut self, ctx: ConstraintContext) -> Self {
72 self.default_context = ctx;
73 self
74 }
75
76 pub fn check_with_context(
78 &self,
79 subject: &str,
80 action: &str,
81 resource: &str,
82 ctx: &ConstraintContext,
83 ) -> PolicyResult {
84 let candidates = self.candidate_rules(subject, action);
85 debug!(
86 subject,
87 action,
88 resource,
89 n_candidates = candidates.len(),
90 "odrl check"
91 );
92
93 let mut permission_matches: Vec<RuleMatch> = Vec::new();
95 let mut prohibition_match: Option<(String, String, Vec<ConstraintEval>)> = None;
96
97 for rule_ref in candidates {
98 let policy = &self.doc.policies[rule_ref.policy_index];
99 let rule = &policy.rules[rule_ref.rule_index];
100
101 if !target_matches(&rule.target, resource) {
103 continue;
104 }
105
106 let constraint_evals: Vec<ConstraintEval> = rule
108 .constraints
109 .iter()
110 .map(|c| ConstraintEval {
111 operand: c.left_operand.clone(),
112 passed: evaluate(c, ctx),
113 })
114 .collect();
115
116 let all_passed = constraint_evals.iter().all(|e| e.passed);
117
118 match rule.rule_type {
119 OdrlRuleType::Prohibition if all_passed => {
120 let reason = format!(
121 "prohibited by policy '{}' (action '{}' on '{}')",
122 policy.uid, action, resource
123 );
124 if prohibition_match.is_none() {
125 prohibition_match = Some((policy.uid.clone(), reason, constraint_evals));
126 }
127 }
130 OdrlRuleType::Permission if all_passed => {
131 permission_matches.push(RuleMatch {
132 policy_uid: policy.uid.clone(),
133 evals: constraint_evals,
134 });
135 }
137 _ => {} }
139 }
140
141 if let Some((policy_uid, reason, evals)) = prohibition_match {
143 for permission_match in permission_matches {
144 let event = OdrlAuditEvent {
145 policy_uid: permission_match.policy_uid,
146 matched_rule: Some(OdrlRuleType::Permission),
147 subject: subject.to_owned(),
148 action: action.to_owned(),
149 target: resource.to_owned(),
150 verdict: OdrlVerdict::Overridden {
151 by_policy: policy_uid.clone(),
152 reason: reason.clone(),
153 },
154 constraint_results: permission_match.evals,
155 };
156 event.log();
157 }
158
159 let event = OdrlAuditEvent {
160 policy_uid: policy_uid.to_owned(),
161 matched_rule: Some(OdrlRuleType::Prohibition),
162 subject: subject.to_owned(),
163 action: action.to_owned(),
164 target: resource.to_owned(),
165 verdict: OdrlVerdict::Prohibited {
166 reason: reason.clone(),
167 },
168 constraint_results: evals,
169 };
170 event.log();
171 return PolicyResult::Deny(reason);
172 }
173
174 if let Some(permission_match) = permission_matches.pop() {
175 let event = OdrlAuditEvent {
176 policy_uid: permission_match.policy_uid,
177 matched_rule: Some(OdrlRuleType::Permission),
178 subject: subject.to_owned(),
179 action: action.to_owned(),
180 target: resource.to_owned(),
181 verdict: OdrlVerdict::Permitted,
182 constraint_results: permission_match.evals,
183 };
184 event.log();
185 return PolicyResult::Allow;
186 }
187
188 let event = OdrlAuditEvent {
190 policy_uid: "<none>".to_owned(),
191 matched_rule: None,
192 subject: subject.to_owned(),
193 action: action.to_owned(),
194 target: resource.to_owned(),
195 verdict: OdrlVerdict::NotApplicable,
196 constraint_results: vec![],
197 };
198 event.log();
199 PolicyResult::delegate("odrl", "no matching ODRL rule")
200 }
201
202 fn candidate_rules(&self, subject: &str, action: &str) -> Vec<RuleRef> {
203 let mut candidates = Vec::new();
204
205 if let Some(exact) = self
206 .exact_rules
207 .get(&(subject.to_owned(), action.to_owned()))
208 {
209 candidates.extend_from_slice(exact);
210 }
211
212 if let Some(wildcard) = self.wildcard_action_rules.get(subject) {
213 candidates.extend_from_slice(wildcard);
214 }
215
216 if candidates.len() > 1 {
217 candidates.sort_by_key(|rule_ref| rule_ref.ordinal);
218 }
219
220 candidates
221 }
222}
223
224impl PolicyEngine for OdrlEngine {
225 fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
226 self.check_with_context(
227 subject.as_str(),
228 action,
229 resource.as_str(),
230 &self.default_context,
231 )
232 }
233
234 fn check_with_context(
235 &self,
236 subject: &SubjectId,
237 action: &str,
238 resource: &ResourceId,
239 ctx: &RequestContext,
240 ) -> PolicyResult {
241 let ctx = ConstraintContext::from(ctx);
242 self.check_with_context(subject.as_str(), action, resource.as_str(), &ctx)
243 }
244}
245
246fn target_matches(target: &str, resource: &str) -> bool {
249 if target == resource {
250 return true;
251 }
252 let stripped = target.strip_prefix("asset:").unwrap_or(target);
254 if stripped == resource {
255 return true;
256 }
257 Pattern::new(stripped).is_ok_and(|p| p.matches(resource))
258}
259
260fn build_rule_index(doc: &OdrlDocument) -> (RuleIndex, WildcardActionIndex) {
261 let mut exact_rules: RuleIndex = HashMap::new();
262 let mut wildcard_action_rules: WildcardActionIndex = HashMap::new();
263 let mut ordinal = 0;
264
265 for (policy_index, policy) in doc.policies.iter().enumerate() {
266 for (rule_index, rule) in policy.rules.iter().enumerate() {
267 let rule_ref = RuleRef {
268 policy_index,
269 rule_index,
270 ordinal,
271 };
272 ordinal += 1;
273
274 if matches!(rule.action, RuleAction::Use) {
275 wildcard_action_rules
276 .entry(rule.assignee.clone())
277 .or_default()
278 .push(rule_ref);
279 } else {
280 exact_rules
281 .entry((
282 rule.assignee.clone(),
283 rule.action.as_permission_name().to_owned(),
284 ))
285 .or_default()
286 .push(rule_ref);
287 }
288 }
289 }
290
291 (exact_rules, wildcard_action_rules)
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 const YAML: &str = r#"
299policies:
300 - uid: "policy:ai-agent-001"
301 type: Set
302 rules:
303 - type: permission
304 assigner: "org:acme"
305 assignee: "agent:summarizer"
306 action: read
307 target: "asset:customer-data"
308 constraints:
309 - leftOperand: purpose
310 operator: eq
311 rightOperand: "analytics"
312 - leftOperand: dateTime
313 operator: lt
314 rightOperand: "2099-01-01T00:00:00Z"
315 - type: prohibition
316 assignee: "agent:summarizer"
317 action: exfiltrate
318 target: "asset:customer-data"
319"#;
320
321 fn engine() -> OdrlEngine {
322 OdrlEngine::from_yaml(YAML).expect("engine build ok")
323 }
324
325 #[test]
326 fn read_allowed_with_correct_purpose() {
327 let e = engine();
328 let ctx = ConstraintContext::default().with_purpose("analytics");
329 let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
330 assert_eq!(result, PolicyResult::Allow);
331 }
332
333 #[test]
334 fn read_denied_wrong_purpose() {
335 let e = engine();
336 let ctx = ConstraintContext::default().with_purpose("billing");
337 let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
338 assert!(matches!(result, PolicyResult::Delegate(_)));
340 }
341
342 #[test]
343 fn exfiltrate_is_prohibited() {
344 let e = engine();
345 let ctx = ConstraintContext::default();
346 let result =
347 e.check_with_context("agent:summarizer", "ai:exfiltrate", "customer-data", &ctx);
348 assert!(matches!(result, PolicyResult::Deny(_)));
349 }
350
351 #[test]
352 fn unknown_subject_delegates() {
353 let e = engine();
354 let ctx = ConstraintContext::default().with_purpose("analytics");
355 let result = e.check_with_context("agent:unknown", "read", "customer-data", &ctx);
356 assert!(matches!(result, PolicyResult::Delegate(_)));
357 }
358
359 #[test]
360 fn exact_rule_index_is_built_at_construction() {
361 let e = engine();
362 assert_eq!(
363 e.exact_rules
364 .get(&("agent:summarizer".to_owned(), "read".to_owned()))
365 .expect("read rule indexed")
366 .len(),
367 1
368 );
369 assert_eq!(
370 e.exact_rules
371 .get(&("agent:summarizer".to_owned(), "ai:exfiltrate".to_owned()))
372 .expect("exfiltrate rule indexed")
373 .len(),
374 1
375 );
376 }
377
378 #[test]
379 fn indexed_use_action_matches_any_action() {
380 let yaml = r#"
381policies:
382 - uid: "policy:any-action"
383 type: Set
384 rules:
385 - type: permission
386 assigner: "org:acme"
387 assignee: "agent:operator"
388 action: use
389 target: "asset:ops/*"
390"#;
391 let e = OdrlEngine::from_yaml(yaml).expect("engine build ok");
392 assert_eq!(
393 e.wildcard_action_rules
394 .get("agent:operator")
395 .expect("use rule indexed")
396 .len(),
397 1
398 );
399
400 let ctx = ConstraintContext::default();
401 let result = e.check_with_context("agent:operator", "execute", "ops/restart", &ctx);
402 assert_eq!(result, PolicyResult::Allow);
403 }
404
405 #[test]
406 fn indexed_exact_action_still_checks_target_globs() {
407 let yaml = r#"
408policies:
409 - uid: "policy:reports"
410 type: Set
411 rules:
412 - type: permission
413 assigner: "org:acme"
414 assignee: "agent:analyst"
415 action: read
416 target: "asset:reports/**"
417"#;
418 let e = OdrlEngine::from_yaml(yaml).expect("engine build ok");
419 let ctx = ConstraintContext::default();
420
421 assert_eq!(
422 e.check_with_context("agent:analyst", "read", "reports/2026/q1", &ctx),
423 PolicyResult::Allow
424 );
425 assert!(matches!(
426 e.check_with_context("agent:analyst", "read", "metrics/q1", &ctx),
427 PolicyResult::Delegate(_)
428 ));
429 }
430
431 #[test]
432 fn prohibition_does_not_stop_later_permission_scan() {
433 let yaml = r#"
434policies:
435 - uid: "policy:block"
436 type: Set
437 rules:
438 - type: prohibition
439 assignee: "agent:summarizer"
440 action: read
441 target: "asset:customer-data"
442 - uid: "policy:allow"
443 type: Set
444 rules:
445 - type: permission
446 assigner: "org:acme"
447 assignee: "agent:summarizer"
448 action: read
449 target: "asset:customer-data"
450"#;
451 let e = OdrlEngine::from_yaml(yaml).expect("engine build ok");
452 let ctx = ConstraintContext::default();
453 let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
454 assert!(matches!(result, PolicyResult::Deny(_)));
455 }
456}