Skip to main content

typesec_odrl/
engine.rs

1//! ODRL policy engine — implements [`PolicyEngine`] for an [`OdrlDocument`].
2
3use 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
13/// An ODRL policy engine.
14///
15/// The engine holds a parsed [`OdrlDocument`] and evaluates requests against
16/// all matching rules. It applies ODRL's conflict resolution: **prohibitions
17/// take precedence over permissions** when both match the same (subject, action, target).
18///
19/// Every check emits a structured [`OdrlAuditEvent`] via `tracing`.
20pub struct OdrlEngine {
21    doc: OdrlDocument,
22    /// Default context applied to every check (can be overridden per-check).
23    default_context: ConstraintContext,
24}
25
26impl OdrlEngine {
27    /// Build an engine from a parsed document.
28    pub fn new(doc: OdrlDocument) -> Self {
29        Self {
30            doc,
31            default_context: ConstraintContext::default(),
32        }
33    }
34
35    /// Parse a YAML string and build an engine.
36    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    /// Override the default constraint context (e.g., set purpose for all checks).
43    pub fn with_context(mut self, ctx: ConstraintContext) -> Self {
44        self.default_context = ctx;
45        self
46    }
47
48    /// Run a check with a specific context (overrides per-call).
49    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        // Collect all matching rules across all policies.
59        let mut permission_match: Option<(&str, Vec<ConstraintEval>)> = None; // (policy_uid, evals)
60        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                // Check assignee matches.
65                if rule.assignee != subject {
66                    continue;
67                }
68                // Check action matches.
69                if !rule.action.matches_action(action) {
70                    continue;
71                }
72                // Check target (glob) matches.
73                if !target_matches(&rule.target, resource) {
74                    continue;
75                }
76
77                // Evaluate constraints.
78                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                        // ODRL: prohibitions take priority — stop scanning.
97                        break 'policies;
98                    }
99                    OdrlRuleType::Permission if all_passed => {
100                        permission_match = Some((&policy.uid, constraint_evals));
101                        // Don't break: a later prohibition might override this.
102                    }
103                    _ => {} // duty, or constraint failed
104                }
105            }
106        }
107
108        // Resolution: prohibition wins over permission.
109        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        // No rule matched — delegate to an outer engine (e.g., RBAC).
140        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
160/// Match a target string (which may be an ODRL URI or a glob pattern) against
161/// a resource identifier.
162fn target_matches(target: &str, resource: &str) -> bool {
163    if target == resource {
164        return true;
165    }
166    // Strip `"asset:"` prefix if present for simple matching.
167    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        // No permission matched (purpose constraint failed) → delegate
219        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}