typesec-odrl 0.11.0

ODRL policy engine for typesec — W3C digital rights language subset
Documentation
//! Constraint evaluation for ODRL rules.
//!
//! Constraints are runtime conditions: purpose, date/time, count, etc.
//! This module evaluates them given a [`ConstraintContext`].

use chrono::{DateTime, Utc};
use tracing::debug;
use typesec_core::policy::RequestContext;

use crate::model::{ConstraintOperand, ConstraintOperator, OdrlConstraint};

/// Context provided when evaluating constraints.
///
/// The context carries *all* information about the current request that
/// constraints might need to evaluate. Add fields here as your policies grow.
#[derive(Debug, Clone, Default)]
pub struct ConstraintContext {
    /// The purpose of this access request (e.g., `"analytics"`, `"audit"`).
    pub purpose: Option<String>,
    /// The current time (defaults to `Utc::now()` if not specified).
    pub now: Option<DateTime<Utc>>,
    /// An arbitrary key-value store for custom constraint operands.
    pub custom: std::collections::HashMap<String, String>,
}

impl ConstraintContext {
    /// Create a context with a given purpose.
    pub fn with_purpose(mut self, purpose: impl Into<String>) -> Self {
        self.purpose = Some(purpose.into());
        self
    }

    /// Create a context with a specific time (useful for testing).
    pub fn with_time(mut self, t: DateTime<Utc>) -> Self {
        self.now = Some(t);
        self
    }

    /// Add a custom key-value pair.
    pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.custom.insert(key.into(), value.into());
        self
    }

    fn effective_now(&self) -> DateTime<Utc> {
        self.now.unwrap_or_else(Utc::now)
    }
}

impl From<&RequestContext> for ConstraintContext {
    fn from(ctx: &RequestContext) -> Self {
        Self {
            purpose: ctx.purpose.clone(),
            now: None,
            custom: ctx.custom.clone(),
        }
    }
}

/// Evaluate a single ODRL constraint against a context.
///
/// Returns `true` if the constraint is satisfied, `false` otherwise.
pub fn evaluate(constraint: &OdrlConstraint, ctx: &ConstraintContext) -> bool {
    debug!(
        left = %constraint.left_operand,
        op = ?constraint.operator,
        right = %constraint.right_operand,
        "evaluating constraint"
    );

    match &constraint.left_operand {
        ConstraintOperand::Purpose => evaluate_purpose(constraint, ctx),
        ConstraintOperand::DateTime => evaluate_datetime(constraint, ctx),
        ConstraintOperand::Count => evaluate_custom_operand("count", constraint, ctx),
        ConstraintOperand::Custom(name) => evaluate_custom_operand(name, constraint, ctx),
    }
}

fn evaluate_custom_operand(
    operand: &str,
    constraint: &OdrlConstraint,
    ctx: &ConstraintContext,
) -> bool {
    if let Some(val) = ctx.custom.get(operand) {
        evaluate_string_op(&constraint.operator, val, &constraint.right_operand)
    } else {
        debug!("unknown constraint left operand '{operand}' — failing closed");
        false
    }
}

fn evaluate_purpose(constraint: &OdrlConstraint, ctx: &ConstraintContext) -> bool {
    let actual = match ctx.purpose.as_deref() {
        Some(p) => p,
        None => return false,
    };
    evaluate_string_op(&constraint.operator, actual, &constraint.right_operand)
}

fn evaluate_datetime(constraint: &OdrlConstraint, ctx: &ConstraintContext) -> bool {
    let now = ctx.effective_now();

    let rhs = match constraint.right_operand.parse::<DateTime<Utc>>() {
        Ok(dt) => dt,
        Err(e) => {
            debug!(
                "could not parse dateTime '{}': {e}",
                constraint.right_operand
            );
            return false;
        }
    };

    match constraint.operator {
        ConstraintOperator::Lt => now < rhs,
        ConstraintOperator::Lteq => now <= rhs,
        ConstraintOperator::Gt => now > rhs,
        ConstraintOperator::Gteq => now >= rhs,
        ConstraintOperator::Eq => now == rhs,
        ConstraintOperator::Neq => now != rhs,
        ConstraintOperator::IsPartOf => false, // doesn't apply to dates
    }
}

fn evaluate_string_op(op: &ConstraintOperator, actual: &str, expected: &str) -> bool {
    match op {
        // Equality compares numerically when *both* sides parse as numbers, so
        // `count eq 5` holds for an actual of `"5.0"` or `"05"` — consistent with
        // the ordering operators below. Non-numeric operands compare as strings.
        ConstraintOperator::Eq => match (actual.parse::<f64>(), expected.parse::<f64>()) {
            (Ok(a), Ok(b)) => a == b,
            _ => actual == expected,
        },
        ConstraintOperator::Neq => match (actual.parse::<f64>(), expected.parse::<f64>()) {
            (Ok(a), Ok(b)) => a != b,
            _ => actual != expected,
        },
        ConstraintOperator::IsPartOf => expected.split(',').any(|v| v.trim() == actual),
        // Ordering operators compare numerically when *both* sides parse as
        // numbers — so `count lteq 5` is arithmetic (10 is not <= 5), not
        // lexicographic (where "10" < "5"). Non-numeric operands (string tags)
        // fall back to lexicographic ordering.
        ConstraintOperator::Lt
        | ConstraintOperator::Lteq
        | ConstraintOperator::Gt
        | ConstraintOperator::Gteq => match (actual.parse::<f64>(), expected.parse::<f64>()) {
            (Ok(a), Ok(b)) => apply_ordering(op, &a, &b),
            _ => apply_ordering(op, &actual, &expected),
        },
    }
}

/// Apply an ordering operator to two comparable values. Eq/Neq/IsPartOf are
/// handled by the caller and never reach here.
fn apply_ordering<T: PartialOrd>(op: &ConstraintOperator, a: &T, b: &T) -> bool {
    match op {
        ConstraintOperator::Lt => a < b,
        ConstraintOperator::Lteq => a <= b,
        ConstraintOperator::Gt => a > b,
        ConstraintOperator::Gteq => a >= b,
        _ => false,
    }
}

#[cfg(test)]
mod tests;