Skip to main content

typesec_odrl/
engine.rs

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