use glob::Pattern;
use tracing::debug;
use typesec_core::policy::{PolicyEngine, PolicyResult};
use crate::{
audit::{ConstraintEval, OdrlAuditEvent, OdrlVerdict},
constraint::{ConstraintContext, evaluate},
model::{OdrlDocument, OdrlRuleType},
};
pub struct OdrlEngine {
doc: OdrlDocument,
default_context: ConstraintContext,
}
impl OdrlEngine {
pub fn new(doc: OdrlDocument) -> Self {
Self {
doc,
default_context: ConstraintContext::default(),
}
}
pub fn from_yaml(yaml: &str) -> Result<Self, String> {
let doc =
OdrlDocument::from_yaml(yaml).map_err(|e| format!("ODRL YAML parse error: {e}"))?;
Ok(Self::new(doc))
}
pub fn with_context(mut self, ctx: ConstraintContext) -> Self {
self.default_context = ctx;
self
}
pub fn check_with_context(
&self,
subject: &str,
action: &str,
resource: &str,
ctx: &ConstraintContext,
) -> PolicyResult {
debug!(subject, action, resource, "odrl check");
let mut permission_match: Option<(&str, Vec<ConstraintEval>)> = None; let mut prohibition_match: Option<(&str, String, Vec<ConstraintEval>)> = None;
'policies: for policy in &self.doc.policies {
for rule in &policy.rules {
if rule.assignee != subject {
continue;
}
if !rule.action.matches_action(action) {
continue;
}
if !target_matches(&rule.target, resource) {
continue;
}
let constraint_evals: Vec<ConstraintEval> = rule
.constraints
.iter()
.map(|c| ConstraintEval {
operand: c.left_operand.clone(),
passed: evaluate(c, ctx),
})
.collect();
let all_passed = constraint_evals.iter().all(|e| e.passed);
match rule.rule_type {
OdrlRuleType::Prohibition if all_passed => {
let reason = format!(
"prohibited by policy '{}' (action '{}' on '{}')",
policy.uid, action, resource
);
prohibition_match = Some((&policy.uid, reason, constraint_evals));
break 'policies;
}
OdrlRuleType::Permission if all_passed => {
permission_match = Some((&policy.uid, constraint_evals));
}
_ => {} }
}
}
if let Some((policy_uid, reason, evals)) = prohibition_match {
let event = OdrlAuditEvent {
policy_uid: policy_uid.to_owned(),
matched_rule: Some(OdrlRuleType::Prohibition),
subject: subject.to_owned(),
action: action.to_owned(),
target: resource.to_owned(),
verdict: OdrlVerdict::Prohibited {
reason: reason.clone(),
},
constraint_results: evals,
};
event.log();
return PolicyResult::Deny(reason);
}
if let Some((policy_uid, evals)) = permission_match {
let event = OdrlAuditEvent {
policy_uid: policy_uid.to_owned(),
matched_rule: Some(OdrlRuleType::Permission),
subject: subject.to_owned(),
action: action.to_owned(),
target: resource.to_owned(),
verdict: OdrlVerdict::Permitted,
constraint_results: evals,
};
event.log();
return PolicyResult::Allow;
}
let event = OdrlAuditEvent {
policy_uid: "<none>".to_owned(),
matched_rule: None,
subject: subject.to_owned(),
action: action.to_owned(),
target: resource.to_owned(),
verdict: OdrlVerdict::NotApplicable,
constraint_results: vec![],
};
event.log();
PolicyResult::Delegate("no matching ODRL rule — delegating".into())
}
}
impl PolicyEngine for OdrlEngine {
fn check(&self, subject: &str, action: &str, resource: &str) -> PolicyResult {
self.check_with_context(subject, action, resource, &self.default_context)
}
}
fn target_matches(target: &str, resource: &str) -> bool {
if target == resource {
return true;
}
let stripped = target.strip_prefix("asset:").unwrap_or(target);
if stripped == resource {
return true;
}
Pattern::new(stripped).is_ok_and(|p| p.matches(resource))
}
#[cfg(test)]
mod tests {
use super::*;
const YAML: &str = r#"
policies:
- uid: "policy:ai-agent-001"
type: Set
rules:
- type: permission
assigner: "org:acme"
assignee: "agent:summarizer"
action: read
target: "asset:customer-data"
constraints:
- leftOperand: purpose
operator: eq
rightOperand: "analytics"
- leftOperand: dateTime
operator: lt
rightOperand: "2099-01-01T00:00:00Z"
- type: prohibition
assignee: "agent:summarizer"
action: exfiltrate
target: "asset:customer-data"
"#;
fn engine() -> OdrlEngine {
OdrlEngine::from_yaml(YAML).expect("engine build ok")
}
#[test]
fn read_allowed_with_correct_purpose() {
let e = engine();
let ctx = ConstraintContext::default().with_purpose("analytics");
let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
assert_eq!(result, PolicyResult::Allow);
}
#[test]
fn read_denied_wrong_purpose() {
let e = engine();
let ctx = ConstraintContext::default().with_purpose("billing");
let result = e.check_with_context("agent:summarizer", "read", "customer-data", &ctx);
assert!(matches!(result, PolicyResult::Delegate(_)));
}
#[test]
fn exfiltrate_is_prohibited() {
let e = engine();
let ctx = ConstraintContext::default();
let result =
e.check_with_context("agent:summarizer", "ai:exfiltrate", "customer-data", &ctx);
assert!(matches!(result, PolicyResult::Deny(_)));
}
#[test]
fn unknown_subject_delegates() {
let e = engine();
let ctx = ConstraintContext::default().with_purpose("analytics");
let result = e.check_with_context("agent:unknown", "read", "customer-data", &ctx);
assert!(matches!(result, PolicyResult::Delegate(_)));
}
}