1use glob::Pattern;
4use tracing::debug;
5use typesec_core::policy::{PolicyEngine, PolicyResult};
6
7use crate::{
8 audit::{ConstraintEval, OdrlAuditEvent, OdrlVerdict},
9 constraint::{ConstraintContext, evaluate},
10 model::{OdrlDocument, OdrlRuleType},
11};
12
13pub struct OdrlEngine {
21 doc: OdrlDocument,
22 default_context: ConstraintContext,
24}
25
26impl OdrlEngine {
27 pub fn new(doc: OdrlDocument) -> Self {
29 Self {
30 doc,
31 default_context: ConstraintContext::default(),
32 }
33 }
34
35 pub fn from_yaml(yaml: &str) -> Result<Self, String> {
37 let doc =
38 OdrlDocument::from_yaml(yaml).map_err(|e| format!("ODRL YAML parse error: {e}"))?;
39 Ok(Self::new(doc))
40 }
41
42 pub fn with_context(mut self, ctx: ConstraintContext) -> Self {
44 self.default_context = ctx;
45 self
46 }
47
48 pub fn check_with_context(
50 &self,
51 subject: &str,
52 action: &str,
53 resource: &str,
54 ctx: &ConstraintContext,
55 ) -> PolicyResult {
56 debug!(subject, action, resource, "odrl check");
57
58 let mut permission_match: Option<(&str, Vec<ConstraintEval>)> = None; let mut prohibition_match: Option<(&str, String, Vec<ConstraintEval>)> = None;
61
62 'policies: for policy in &self.doc.policies {
63 for rule in &policy.rules {
64 if rule.assignee != subject {
66 continue;
67 }
68 if !rule.action.matches_action(action) {
70 continue;
71 }
72 if !target_matches(&rule.target, resource) {
74 continue;
75 }
76
77 let constraint_evals: Vec<ConstraintEval> = rule
79 .constraints
80 .iter()
81 .map(|c| ConstraintEval {
82 operand: c.left_operand.clone(),
83 passed: evaluate(c, ctx),
84 })
85 .collect();
86
87 let all_passed = constraint_evals.iter().all(|e| e.passed);
88
89 match rule.rule_type {
90 OdrlRuleType::Prohibition if all_passed => {
91 let reason = format!(
92 "prohibited by policy '{}' (action '{}' on '{}')",
93 policy.uid, action, resource
94 );
95 prohibition_match = Some((&policy.uid, reason, constraint_evals));
96 break 'policies;
98 }
99 OdrlRuleType::Permission if all_passed => {
100 permission_match = Some((&policy.uid, constraint_evals));
101 }
103 _ => {} }
105 }
106 }
107
108 if let Some((policy_uid, reason, evals)) = prohibition_match {
110 let event = OdrlAuditEvent {
111 policy_uid: policy_uid.to_owned(),
112 matched_rule: Some(OdrlRuleType::Prohibition),
113 subject: subject.to_owned(),
114 action: action.to_owned(),
115 target: resource.to_owned(),
116 verdict: OdrlVerdict::Prohibited {
117 reason: reason.clone(),
118 },
119 constraint_results: evals,
120 };
121 event.log();
122 return PolicyResult::Deny(reason);
123 }
124
125 if let Some((policy_uid, evals)) = permission_match {
126 let event = OdrlAuditEvent {
127 policy_uid: policy_uid.to_owned(),
128 matched_rule: Some(OdrlRuleType::Permission),
129 subject: subject.to_owned(),
130 action: action.to_owned(),
131 target: resource.to_owned(),
132 verdict: OdrlVerdict::Permitted,
133 constraint_results: evals,
134 };
135 event.log();
136 return PolicyResult::Allow;
137 }
138
139 let event = OdrlAuditEvent {
141 policy_uid: "<none>".to_owned(),
142 matched_rule: None,
143 subject: subject.to_owned(),
144 action: action.to_owned(),
145 target: resource.to_owned(),
146 verdict: OdrlVerdict::NotApplicable,
147 constraint_results: vec![],
148 };
149 event.log();
150 PolicyResult::Delegate("no matching ODRL rule — delegating".into())
151 }
152}
153
154impl PolicyEngine for OdrlEngine {
155 fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
156 self.check_with_context(subject, action, resource, &self.default_context)
157 }
158}
159
160fn target_matches(target: &str, resource: &str) -> bool {
163 if target == resource {
164 return true;
165 }
166 let stripped = target.strip_prefix("asset:").unwrap_or(target);
168 if stripped == resource {
169 return true;
170 }
171 Pattern::new(stripped).is_ok_and(|p| p.matches(resource))
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 const YAML: &str = r#"
179policies:
180 - uid: "policy:ai-agent-001"
181 type: Set
182 rules:
183 - type: permission
184 assigner: "org:acme"
185 assignee: "agent:summarizer"
186 action: read
187 target: "asset:customer-data"
188 constraints:
189 - leftOperand: purpose
190 operator: eq
191 rightOperand: "analytics"
192 - leftOperand: dateTime
193 operator: lt
194 rightOperand: "2099-01-01T00:00:00Z"
195 - type: prohibition
196 assignee: "agent:summarizer"
197 action: exfiltrate
198 target: "asset:customer-data"
199"#;
200
201 fn engine() -> OdrlEngine {
202 OdrlEngine::from_yaml(YAML).expect("engine build ok")
203 }
204
205 #[test]
206 fn read_allowed_with_correct_purpose() {
207 let e = engine();
208 let ctx = ConstraintContext::default().with_purpose("analytics");
209 let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
210 assert_eq!(result, PolicyResult::Allow);
211 }
212
213 #[test]
214 fn read_denied_wrong_purpose() {
215 let e = engine();
216 let ctx = ConstraintContext::default().with_purpose("billing");
217 let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
218 assert!(matches!(result, PolicyResult::Delegate(_)));
220 }
221
222 #[test]
223 fn exfiltrate_is_prohibited() {
224 let e = engine();
225 let ctx = ConstraintContext::default();
226 let result =
227 e.check_with_context("agent:summarizer", "ai:exfiltrate", "customer-data", &ctx);
228 assert!(matches!(result, PolicyResult::Deny(_)));
229 }
230
231 #[test]
232 fn unknown_subject_delegates() {
233 let e = engine();
234 let ctx = ConstraintContext::default().with_purpose("analytics");
235 let result = e.check_with_context("agent:unknown", "read", "customer-data", &ctx);
236 assert!(matches!(result, PolicyResult::Delegate(_)));
237 }
238}