Skip to main content

typesec_odrl/
constraint.rs

1//! Constraint evaluation for ODRL rules.
2//!
3//! Constraints are runtime conditions: purpose, date/time, count, etc.
4//! This module evaluates them given a [`ConstraintContext`].
5
6use chrono::{DateTime, Utc};
7use tracing::debug;
8use typesec_core::policy::RequestContext;
9
10use crate::model::{ConstraintOperand, ConstraintOperator, OdrlConstraint};
11
12/// Context provided when evaluating constraints.
13///
14/// The context carries *all* information about the current request that
15/// constraints might need to evaluate. Add fields here as your policies grow.
16#[derive(Debug, Clone, Default)]
17pub struct ConstraintContext {
18    /// The purpose of this access request (e.g., `"analytics"`, `"audit"`).
19    pub purpose: Option<String>,
20    /// The current time (defaults to `Utc::now()` if not specified).
21    pub now: Option<DateTime<Utc>>,
22    /// An arbitrary key-value store for custom constraint operands.
23    pub custom: std::collections::HashMap<String, String>,
24}
25
26impl ConstraintContext {
27    /// Create a context with a given purpose.
28    pub fn with_purpose(mut self, purpose: impl Into<String>) -> Self {
29        self.purpose = Some(purpose.into());
30        self
31    }
32
33    /// Create a context with a specific time (useful for testing).
34    pub fn with_time(mut self, t: DateTime<Utc>) -> Self {
35        self.now = Some(t);
36        self
37    }
38
39    /// Add a custom key-value pair.
40    pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
41        self.custom.insert(key.into(), value.into());
42        self
43    }
44
45    fn effective_now(&self) -> DateTime<Utc> {
46        self.now.unwrap_or_else(Utc::now)
47    }
48}
49
50impl From<&RequestContext> for ConstraintContext {
51    fn from(ctx: &RequestContext) -> Self {
52        Self {
53            purpose: ctx.purpose.clone(),
54            now: None,
55            custom: ctx.custom.clone(),
56        }
57    }
58}
59
60/// Evaluate a single ODRL constraint against a context.
61///
62/// Returns `true` if the constraint is satisfied, `false` otherwise.
63pub fn evaluate(constraint: &OdrlConstraint, ctx: &ConstraintContext) -> bool {
64    debug!(
65        left = %constraint.left_operand,
66        op = ?constraint.operator,
67        right = %constraint.right_operand,
68        "evaluating constraint"
69    );
70
71    match &constraint.left_operand {
72        ConstraintOperand::Purpose => evaluate_purpose(constraint, ctx),
73        ConstraintOperand::DateTime => evaluate_datetime(constraint, ctx),
74        ConstraintOperand::Count => evaluate_custom_operand("count", constraint, ctx),
75        ConstraintOperand::Custom(name) => evaluate_custom_operand(name, constraint, ctx),
76    }
77}
78
79fn evaluate_custom_operand(
80    operand: &str,
81    constraint: &OdrlConstraint,
82    ctx: &ConstraintContext,
83) -> bool {
84    if let Some(val) = ctx.custom.get(operand) {
85        evaluate_string_op(&constraint.operator, val, &constraint.right_operand)
86    } else {
87        debug!("unknown constraint left operand '{operand}' — failing closed");
88        false
89    }
90}
91
92fn evaluate_purpose(constraint: &OdrlConstraint, ctx: &ConstraintContext) -> bool {
93    let actual = match ctx.purpose.as_deref() {
94        Some(p) => p,
95        None => return false,
96    };
97    evaluate_string_op(&constraint.operator, actual, &constraint.right_operand)
98}
99
100fn evaluate_datetime(constraint: &OdrlConstraint, ctx: &ConstraintContext) -> bool {
101    let now = ctx.effective_now();
102
103    let rhs = match constraint.right_operand.parse::<DateTime<Utc>>() {
104        Ok(dt) => dt,
105        Err(e) => {
106            debug!(
107                "could not parse dateTime '{}': {e}",
108                constraint.right_operand
109            );
110            return false;
111        }
112    };
113
114    match constraint.operator {
115        ConstraintOperator::Lt => now < rhs,
116        ConstraintOperator::Lteq => now <= rhs,
117        ConstraintOperator::Gt => now > rhs,
118        ConstraintOperator::Gteq => now >= rhs,
119        ConstraintOperator::Eq => now == rhs,
120        ConstraintOperator::Neq => now != rhs,
121        ConstraintOperator::IsPartOf => false, // doesn't apply to dates
122    }
123}
124
125fn evaluate_string_op(op: &ConstraintOperator, actual: &str, expected: &str) -> bool {
126    match op {
127        ConstraintOperator::Eq => actual == expected,
128        ConstraintOperator::Neq => actual != expected,
129        ConstraintOperator::IsPartOf => expected.split(',').any(|v| v.trim() == actual),
130        // Lexicographic ordering for strings (reasonable for simple tags).
131        ConstraintOperator::Lt => actual < expected,
132        ConstraintOperator::Lteq => actual <= expected,
133        ConstraintOperator::Gt => actual > expected,
134        ConstraintOperator::Gteq => actual >= expected,
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::model::{ConstraintOperand, ConstraintOperator, OdrlConstraint};
142
143    fn make_constraint(left: &str, op: ConstraintOperator, right: &str) -> OdrlConstraint {
144        OdrlConstraint {
145            left_operand: ConstraintOperand::parse(left),
146            operator: op,
147            right_operand: right.into(),
148        }
149    }
150
151    #[test]
152    fn purpose_eq_passes() {
153        let c = make_constraint("purpose", ConstraintOperator::Eq, "analytics");
154        let ctx = ConstraintContext::default().with_purpose("analytics");
155        assert!(evaluate(&c, &ctx));
156    }
157
158    #[test]
159    fn purpose_eq_fails_wrong_value() {
160        let c = make_constraint("purpose", ConstraintOperator::Eq, "analytics");
161        let ctx = ConstraintContext::default().with_purpose("billing");
162        assert!(!evaluate(&c, &ctx));
163    }
164
165    #[test]
166    fn purpose_is_part_of() {
167        let c = make_constraint(
168            "purpose",
169            ConstraintOperator::IsPartOf,
170            "analytics, audit, reporting",
171        );
172        let ctx = ConstraintContext::default().with_purpose("audit");
173        assert!(evaluate(&c, &ctx));
174    }
175
176    #[test]
177    fn datetime_lt_passes_when_before() {
178        let future = "2099-01-01T00:00:00Z";
179        let c = make_constraint("dateTime", ConstraintOperator::Lt, future);
180        let ctx = ConstraintContext::default();
181        assert!(evaluate(&c, &ctx));
182    }
183
184    #[test]
185    fn datetime_lt_fails_when_past() {
186        let past = "2000-01-01T00:00:00Z";
187        let c = make_constraint("dateTime", ConstraintOperator::Lt, past);
188        let ctx = ConstraintContext::default();
189        assert!(!evaluate(&c, &ctx));
190    }
191
192    #[test]
193    fn count_operand_reads_custom_context() {
194        let c = make_constraint("count", ConstraintOperator::Lteq, "5");
195        let ctx = ConstraintContext::default().with("count", "3");
196        assert!(evaluate(&c, &ctx));
197    }
198
199    #[test]
200    fn custom_operand_reads_custom_context() {
201        let c = make_constraint("region", ConstraintOperator::Eq, "eu");
202        let ctx = ConstraintContext::default().with("region", "eu");
203        assert!(evaluate(&c, &ctx));
204    }
205
206    #[test]
207    fn unknown_custom_operand_fails_closed() {
208        let c = make_constraint("region", ConstraintOperator::Eq, "eu");
209        let ctx = ConstraintContext::default();
210        assert!(!evaluate(&c, &ctx));
211    }
212}