Skip to main content

typesec_odrl/
engine.rs

1//! ODRL policy engine — implements [`PolicyEngine`] for an [`OdrlDocument`].
2
3use 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
34/// An ODRL policy engine.
35///
36/// The engine holds a parsed [`OdrlDocument`] and evaluates requests against
37/// all matching rules. It applies ODRL's conflict resolution: **prohibitions
38/// take precedence over permissions** when both match the same (subject, action, target).
39///
40/// Every check emits a structured [`OdrlAuditEvent`] via `tracing`.
41pub struct OdrlEngine {
42    doc: OdrlDocument,
43    /// Exact `(assignee, action)` index for the common case.
44    exact_rules: RuleIndex,
45    /// Same-assignee wildcard action (`use`) rules.
46    wildcard_action_rules: WildcardActionIndex,
47    /// Default context applied to every check (can be overridden per-check).
48    default_context: ConstraintContext,
49}
50
51impl OdrlEngine {
52    /// Build an engine from a parsed document.
53    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    /// Parse a YAML string and build an engine.
64    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    /// Override the default constraint context (e.g., set purpose for all checks).
71    pub fn with_context(mut self, ctx: ConstraintContext) -> Self {
72        self.default_context = ctx;
73        self
74    }
75
76    /// Run a check with a specific context (overrides per-call).
77    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        // Collect all matching indexed rules.
94        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            // Check target (glob) matches.
102            if !target_matches(&rule.target, resource) {
103                continue;
104            }
105
106            // Evaluate constraints.
107            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                    // Keep scanning so permissions overridden by the
128                    // prohibition still appear in the audit trail.
129                }
130                OdrlRuleType::Permission if all_passed => {
131                    permission_matches.push(RuleMatch {
132                        policy_uid: policy.uid.clone(),
133                        evals: constraint_evals,
134                    });
135                    // Don't break: a later prohibition might override this.
136                }
137                _ => {} // duty, or constraint failed
138            }
139        }
140
141        // Resolution: prohibition wins over permission.
142        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        // No rule matched — delegate to an outer engine (e.g., RBAC).
189        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
246/// Match a target string (which may be an ODRL URI or a glob pattern) against
247/// a resource identifier.
248fn target_matches(target: &str, resource: &str) -> bool {
249    if target == resource {
250        return true;
251    }
252    // Strip `"asset:"` prefix if present for simple matching.
253    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        // No permission matched (purpose constraint failed) → delegate
339        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}