Skip to main content

typesec_odrl/
engine.rs

1//! ODRL policy engine — implements [`PolicyEngine`] for an [`OdrlDocument`].
2
3mod index;
4
5use tracing::debug;
6use typesec_core::{
7    ResourceId, SubjectId,
8    policy::{PolicyEngine, PolicyResult, RequestContext},
9};
10
11use crate::{
12    audit::{ConstraintEval, OdrlAuditEvent, OdrlVerdict},
13    constraint::{ConstraintContext, evaluate},
14    model::{OdrlDocument, OdrlRuleType},
15};
16use index::{RuleIndex, RuleRef, WildcardActionIndex, build_rule_index, target_matches};
17
18struct RuleMatch {
19    policy_uid: String,
20    evals: Vec<ConstraintEval>,
21}
22
23/// A rule that matched the target but had at least one failing constraint.
24struct ConstraintFailure {
25    policy_uid: String,
26    rule_type: OdrlRuleType,
27    evals: Vec<ConstraintEval>,
28}
29
30/// A matched prohibition: `(policy_uid, reason, constraint_evals)`.
31type ProhibitionMatch = (String, String, Vec<ConstraintEval>);
32
33/// Outcome of scanning candidate rules, before any audit is emitted.
34struct ScanResult {
35    permission_matches: Vec<RuleMatch>,
36    prohibition_match: Option<ProhibitionMatch>,
37    constraint_failures: Vec<ConstraintFailure>,
38}
39
40/// An ODRL policy engine.
41///
42/// The engine holds a parsed [`OdrlDocument`] and evaluates requests against
43/// all matching rules. It applies ODRL's conflict resolution: **prohibitions
44/// take precedence over permissions** when both match the same (subject, action, target).
45///
46/// Every check emits a structured [`OdrlAuditEvent`] via `tracing`.
47pub struct OdrlEngine {
48    doc: OdrlDocument,
49    /// Exact `(assignee, action)` index for the common case.
50    exact_rules: RuleIndex,
51    /// Same-assignee wildcard action (`use`) rules.
52    wildcard_action_rules: WildcardActionIndex,
53    /// Default context applied to every check (can be overridden per-check).
54    default_context: ConstraintContext,
55}
56
57impl OdrlEngine {
58    /// Build an engine from a parsed document.
59    pub fn new(doc: OdrlDocument) -> Self {
60        let (exact_rules, wildcard_action_rules) = build_rule_index(&doc);
61        Self {
62            doc,
63            exact_rules,
64            wildcard_action_rules,
65            default_context: ConstraintContext::default(),
66        }
67    }
68
69    /// Parse a YAML string and build an engine.
70    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
71        let doc =
72            OdrlDocument::from_yaml(yaml).map_err(|e| format!("ODRL YAML parse error: {e}"))?;
73        Ok(Self::new(doc))
74    }
75
76    /// Override the default constraint context (e.g., set purpose for all checks).
77    pub fn with_context(mut self, ctx: ConstraintContext) -> Self {
78        self.default_context = ctx;
79        self
80    }
81
82    /// Run a check with a specific context (overrides per-call).
83    pub fn check_with_context(
84        &self,
85        subject: &str,
86        action: &str,
87        resource: &str,
88        ctx: &ConstraintContext,
89    ) -> PolicyResult {
90        let (verdict, events) = self.decide(subject, action, resource, ctx);
91        for event in &events {
92            event.log();
93        }
94        verdict
95    }
96
97    /// Evaluate a request and return the verdict together with the full audit
98    /// trail, *without* logging. Keeping this pure makes the audit trail
99    /// testable; [`check_with_context`][Self::check_with_context] logs the events.
100    fn decide(
101        &self,
102        subject: &str,
103        action: &str,
104        resource: &str,
105        ctx: &ConstraintContext,
106    ) -> (PolicyResult, Vec<OdrlAuditEvent>) {
107        let candidates = self.candidate_rules(subject, action);
108        debug!(
109            subject,
110            action,
111            resource,
112            n_candidates = candidates.len(),
113            "odrl check"
114        );
115
116        let scan = self.scan_candidates(&candidates, action, resource, ctx);
117        build_decision(scan, subject, action, resource)
118    }
119
120    /// Scan candidate rules and collect matching permissions and the first
121    /// matching prohibition. Pure: emits no audit and renders no verdict.
122    fn scan_candidates(
123        &self,
124        candidates: &[RuleRef],
125        action: &str,
126        resource: &str,
127        ctx: &ConstraintContext,
128    ) -> ScanResult {
129        let mut permission_matches: Vec<RuleMatch> = Vec::new();
130        let mut prohibition_match: Option<ProhibitionMatch> = None;
131        let mut constraint_failures: Vec<ConstraintFailure> = Vec::new();
132
133        for rule_ref in candidates {
134            let policy = &self.doc.policies[rule_ref.policy_index];
135            let rule = &policy.rules[rule_ref.rule_index];
136
137            // Check target (glob) matches.
138            if !target_matches(&rule.target, resource) {
139                continue;
140            }
141
142            // Evaluate constraints.
143            let constraint_evals: Vec<ConstraintEval> = rule
144                .constraints
145                .iter()
146                .map(|c| ConstraintEval {
147                    operand: c.left_operand.clone(),
148                    passed: evaluate(c, ctx),
149                })
150                .collect();
151
152            let all_passed = constraint_evals.iter().all(|e| e.passed);
153
154            match rule.rule_type {
155                OdrlRuleType::Prohibition if all_passed => {
156                    let reason = format!(
157                        "prohibited by policy '{}' (action '{}' on '{}')",
158                        policy.uid, action, resource
159                    );
160                    if prohibition_match.is_none() {
161                        prohibition_match = Some((policy.uid.clone(), reason, constraint_evals));
162                    }
163                    // Keep scanning so permissions overridden by the
164                    // prohibition still appear in the audit trail.
165                }
166                OdrlRuleType::Permission if all_passed => {
167                    permission_matches.push(RuleMatch {
168                        policy_uid: policy.uid.clone(),
169                        evals: constraint_evals,
170                    });
171                    // Don't break: a later prohibition might override this.
172                }
173                OdrlRuleType::Duty => {
174                    // Duties are obligations, not gating rules. Typesec has no
175                    // fulfillment-tracking model, so a duty is parsed and indexed
176                    // but does not affect the Allow/Deny verdict. Documented no-op.
177                }
178                // A permission or prohibition that matched the target but failed
179                // a constraint — surfaced in the audit trail (below) rather than
180                // silently dropped.
181                _ => {
182                    constraint_failures.push(ConstraintFailure {
183                        policy_uid: policy.uid.clone(),
184                        rule_type: rule.rule_type,
185                        evals: constraint_evals,
186                    });
187                }
188            }
189        }
190
191        ScanResult {
192            permission_matches,
193            prohibition_match,
194            constraint_failures,
195        }
196    }
197
198    fn candidate_rules(&self, subject: &str, action: &str) -> Vec<RuleRef> {
199        let mut candidates = Vec::new();
200
201        if let Some(exact) = self
202            .exact_rules
203            .get(&(subject.to_owned(), action.to_owned()))
204        {
205            candidates.extend_from_slice(exact);
206        }
207
208        if let Some(wildcard) = self.wildcard_action_rules.get(subject) {
209            candidates.extend_from_slice(wildcard);
210        }
211
212        if candidates.len() > 1 {
213            candidates.sort_by_key(|rule_ref| rule_ref.ordinal);
214        }
215
216        candidates
217    }
218}
219
220/// Render an ODRL verdict and the full audit trail for a [`ScanResult`].
221///
222/// Pure (no logging) so the events are testable. Prohibition wins over
223/// permission; every constraint-failed rule and every matched permission is
224/// recorded so the trail reflects the full basis for the decision.
225fn build_decision(
226    scan: ScanResult,
227    subject: &str,
228    action: &str,
229    resource: &str,
230) -> (PolicyResult, Vec<OdrlAuditEvent>) {
231    let ScanResult {
232        permission_matches,
233        prohibition_match,
234        constraint_failures,
235    } = scan;
236    let mut events = Vec::new();
237
238    let event_for = |policy_uid, matched_rule, verdict, constraint_results| OdrlAuditEvent {
239        policy_uid,
240        matched_rule,
241        subject: subject.to_owned(),
242        action: action.to_owned(),
243        target: resource.to_owned(),
244        verdict,
245        constraint_results,
246    };
247
248    // Surface every rule that matched the target but failed a constraint — the
249    // event an auditor most wants, previously dropped silently.
250    for failure in constraint_failures {
251        let failed: Vec<String> = failure
252            .evals
253            .iter()
254            .filter(|e| !e.passed)
255            .map(|e| e.operand.to_string())
256            .collect();
257        events.push(event_for(
258            failure.policy_uid,
259            Some(failure.rule_type),
260            OdrlVerdict::ConstraintFailed {
261                constraint: failed.join(", "),
262            },
263            failure.evals,
264        ));
265    }
266
267    // Resolution: prohibition wins over permission.
268    if let Some((policy_uid, reason, evals)) = prohibition_match {
269        for permission_match in permission_matches {
270            events.push(event_for(
271                permission_match.policy_uid,
272                Some(OdrlRuleType::Permission),
273                OdrlVerdict::Overridden {
274                    by_policy: policy_uid.clone(),
275                    reason: reason.clone(),
276                },
277                permission_match.evals,
278            ));
279        }
280        events.push(event_for(
281            policy_uid,
282            Some(OdrlRuleType::Prohibition),
283            OdrlVerdict::Prohibited {
284                reason: reason.clone(),
285            },
286            evals,
287        ));
288        return (PolicyResult::Deny(reason), events);
289    }
290
291    if !permission_matches.is_empty() {
292        // Record *every* matched permission, not just one, so the trail shows
293        // the full basis for the Allow.
294        for permission_match in permission_matches {
295            events.push(event_for(
296                permission_match.policy_uid,
297                Some(OdrlRuleType::Permission),
298                OdrlVerdict::Permitted,
299                permission_match.evals,
300            ));
301        }
302        return (PolicyResult::Allow, events);
303    }
304
305    // No rule matched — delegate to an outer engine (e.g., RBAC).
306    events.push(event_for(
307        "<none>".to_owned(),
308        None,
309        OdrlVerdict::NotApplicable,
310        vec![],
311    ));
312    (
313        PolicyResult::delegate("odrl", "no matching ODRL rule"),
314        events,
315    )
316}
317
318impl PolicyEngine for OdrlEngine {
319    fn check(&self, subject: &SubjectId, action: &str, resource: &ResourceId) -> PolicyResult {
320        self.check_with_context(
321            subject.as_str(),
322            action,
323            resource.as_str(),
324            &self.default_context,
325        )
326    }
327
328    fn check_with_context(
329        &self,
330        subject: &SubjectId,
331        action: &str,
332        resource: &ResourceId,
333        ctx: &RequestContext,
334    ) -> PolicyResult {
335        let ctx = ConstraintContext::from(ctx);
336        self.check_with_context(subject.as_str(), action, resource.as_str(), &ctx)
337    }
338}
339
340#[cfg(test)]
341mod tests;