use std::net::IpAddr;
use serde::{Deserialize, Serialize};
use crate::{IdprovaError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DatConstraints {
#[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
pub max_actions: Option<u64>,
#[serde(rename = "allowedServers", skip_serializing_if = "Option::is_none")]
pub allowed_servers: Option<Vec<String>>,
#[serde(rename = "requireReceipt", skip_serializing_if = "Option::is_none")]
pub require_receipt: Option<bool>,
#[serde(rename = "rateLimit", skip_serializing_if = "Option::is_none")]
pub rate_limit: Option<RateLimit>,
#[serde(rename = "ipAllowlist", skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<Vec<String>>,
#[serde(rename = "ipDenylist", skip_serializing_if = "Option::is_none")]
pub ip_denylist: Option<Vec<String>>,
#[serde(rename = "minTrustLevel", skip_serializing_if = "Option::is_none")]
pub min_trust_level: Option<u8>,
#[serde(rename = "maxDelegationDepth", skip_serializing_if = "Option::is_none")]
pub max_delegation_depth: Option<u32>,
#[serde(rename = "allowedCountries", skip_serializing_if = "Option::is_none")]
pub allowed_countries: Option<Vec<String>>,
#[serde(rename = "timeWindows", skip_serializing_if = "Option::is_none")]
pub time_windows: Option<Vec<TimeWindow>>,
#[serde(rename = "requiredConfigHash", skip_serializing_if = "Option::is_none")]
pub required_config_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimit {
pub max_actions: u64,
pub window_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeWindow {
pub start_hour: u8,
pub end_hour: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub days_of_week: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Default)]
pub struct EvaluationContext {
pub actions_in_window: u64,
pub request_ip: Option<IpAddr>,
pub agent_trust_level: Option<u8>,
pub delegation_depth: u32,
pub country_code: Option<String>,
pub current_timestamp: Option<i64>,
pub agent_config_hash: Option<String>,
}
impl DatConstraints {
pub fn evaluate(&self, ctx: &EvaluationContext) -> Result<()> {
self.eval_rate_limit(ctx)?;
self.eval_ip_allowlist(ctx)?;
self.eval_ip_denylist(ctx)?;
self.eval_trust_level(ctx)?;
self.eval_delegation_depth(ctx)?;
self.eval_geofence(ctx)?;
self.eval_time_windows(ctx)?;
Ok(())
}
pub fn eval_rate_limit(&self, ctx: &EvaluationContext) -> Result<()> {
if let Some(rl) = &self.rate_limit {
if ctx.actions_in_window >= rl.max_actions {
return Err(IdprovaError::ConstraintViolated(format!(
"rate limit exceeded: {} actions in {}s window (max {})",
ctx.actions_in_window, rl.window_secs, rl.max_actions
)));
}
}
Ok(())
}
pub fn eval_ip_allowlist(&self, ctx: &EvaluationContext) -> Result<()> {
let allowlist = match &self.ip_allowlist {
Some(list) if !list.is_empty() => list,
_ => return Ok(()), };
let ip = match ctx.request_ip {
Some(ip) => ip,
None => {
return Err(IdprovaError::ConstraintViolated(
"ip_allowlist is set but no request IP was provided".into(),
))
}
};
for cidr in allowlist {
if cidr_contains(cidr, ip) {
return Ok(());
}
}
Err(IdprovaError::ConstraintViolated(format!(
"request IP {} is not in the allowlist",
ip
)))
}
pub fn eval_ip_denylist(&self, ctx: &EvaluationContext) -> Result<()> {
let denylist = match &self.ip_denylist {
Some(list) if !list.is_empty() => list,
_ => return Ok(()),
};
let ip = match ctx.request_ip {
Some(ip) => ip,
None => return Ok(()), };
for cidr in denylist {
if cidr_contains(cidr, ip) {
return Err(IdprovaError::ConstraintViolated(format!(
"request IP {} is in the denylist ({})",
ip, cidr
)));
}
}
Ok(())
}
pub fn eval_trust_level(&self, ctx: &EvaluationContext) -> Result<()> {
let min = match self.min_trust_level {
Some(m) => m,
None => return Ok(()),
};
let actual = match ctx.agent_trust_level {
Some(t) => t,
None => {
return Err(IdprovaError::ConstraintViolated(format!(
"min_trust_level {} required but agent trust level was not provided",
min
)))
}
};
if actual < min {
return Err(IdprovaError::ConstraintViolated(format!(
"agent trust level {} is below required minimum {}",
actual, min
)));
}
Ok(())
}
pub fn eval_delegation_depth(&self, ctx: &EvaluationContext) -> Result<()> {
let max = match self.max_delegation_depth {
Some(m) => m,
None => return Ok(()),
};
if ctx.delegation_depth > max {
return Err(IdprovaError::ConstraintViolated(format!(
"delegation depth {} exceeds maximum {}",
ctx.delegation_depth, max
)));
}
Ok(())
}
pub fn eval_geofence(&self, ctx: &EvaluationContext) -> Result<()> {
let allowed = match &self.allowed_countries {
Some(list) if !list.is_empty() => list,
_ => return Ok(()),
};
let country = match &ctx.country_code {
Some(c) => c,
None => {
return Err(IdprovaError::ConstraintViolated(
"allowed_countries is set but no country code was provided".into(),
))
}
};
let upper = country.to_uppercase();
if allowed.iter().any(|a| a.to_uppercase() == upper) {
return Ok(());
}
Err(IdprovaError::ConstraintViolated(format!(
"country '{}' is not in the geofence allowlist",
country
)))
}
pub fn eval_time_windows(&self, ctx: &EvaluationContext) -> Result<()> {
let windows = match &self.time_windows {
Some(w) if !w.is_empty() => w,
_ => return Ok(()),
};
let now_secs = ctx
.current_timestamp
.unwrap_or_else(|| chrono::Utc::now().timestamp());
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(now_secs, 0)
.ok_or_else(|| IdprovaError::ConstraintViolated("invalid timestamp".into()))?;
let hour = dt.hour() as u8;
let dow = dt.weekday().num_days_from_monday() as u8;
for w in windows {
if w.start_hour > 23 || w.end_hour > 23 {
return Err(IdprovaError::ConstraintViolated(
"time window hour out of range (0-23)".into(),
));
}
if let Some(days) = &w.days_of_week {
if !days.contains(&dow) {
continue;
}
}
let in_range = if w.start_hour <= w.end_hour {
hour >= w.start_hour && hour <= w.end_hour
} else {
hour >= w.start_hour || hour <= w.end_hour
};
if in_range {
return Ok(());
}
}
Err(IdprovaError::ConstraintViolated(format!(
"current UTC hour {} is outside all permitted time windows",
hour
)))
}
pub fn eval_config_attestation(
&self,
ctx: &EvaluationContext,
token_config_hash: Option<&str>,
) -> Result<()> {
let required = match &self.required_config_hash {
Some(h) => h,
None => return Ok(()),
};
let token_hash = match token_config_hash {
Some(h) => h,
None => return Err(IdprovaError::ConstraintViolated(
"required_config_hash constraint set but token carries no configAttestation claim"
.into(),
)),
};
if token_hash != required {
return Err(IdprovaError::ConstraintViolated(format!(
"token configAttestation '{}' does not match required hash '{}'",
token_hash, required
)));
}
let live_hash =
match &ctx.agent_config_hash {
Some(h) => h,
None => return Err(IdprovaError::ConstraintViolated(
"required_config_hash constraint set but agent config hash was not provided"
.into(),
)),
};
if live_hash != required {
return Err(IdprovaError::ConstraintViolated(format!(
"agent live config hash '{}' does not match required '{}'",
live_hash, required
)));
}
Ok(())
}
}
fn cidr_contains(cidr_str: &str, ip: IpAddr) -> bool {
let (addr_str, prefix_len) = match cidr_str.split_once('/') {
Some((a, p)) => (a, p.parse::<u32>().unwrap_or(128)),
None => (cidr_str, if cidr_str.contains(':') { 128 } else { 32 }),
};
let Ok(network_addr) = addr_str.parse::<IpAddr>() else {
return false;
};
match (network_addr, ip) {
(IpAddr::V4(net), IpAddr::V4(req)) => {
let prefix = prefix_len.min(32);
if prefix == 0 {
return true;
}
let shift = 32 - prefix;
(u32::from(net) >> shift) == (u32::from(req) >> shift)
}
(IpAddr::V6(net), IpAddr::V6(req)) => {
let prefix = prefix_len.min(128);
if prefix == 0 {
return true;
}
let net_bits = u128::from(net);
let req_bits = u128::from(req);
let shift = 128 - prefix;
(net_bits >> shift) == (req_bits >> shift)
}
_ => false,
}
}
use chrono::Datelike;
use chrono::Timelike;
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn ctx() -> EvaluationContext {
EvaluationContext::default()
}
#[test]
fn test_cidr_ipv4_exact() {
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
assert!(cidr_contains("192.168.1.0/24", ip));
assert!(!cidr_contains("10.0.0.0/8", ip));
}
#[test]
fn test_cidr_ipv4_host() {
let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
assert!(cidr_contains("1.2.3.4", ip));
assert!(cidr_contains("1.2.3.4/32", ip));
assert!(!cidr_contains("1.2.3.5/32", ip));
}
#[test]
fn test_cidr_ipv4_slash0() {
let ip = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(cidr_contains("0.0.0.0/0", ip));
}
#[test]
fn test_cidr_ipv6() {
let ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
assert!(cidr_contains("::1/128", ip));
assert!(!cidr_contains("fe80::/10", ip));
}
#[test]
fn test_cidr_mismatch_family() {
let ipv4 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
assert!(!cidr_contains("::1/128", ipv4));
}
#[test]
fn test_rate_limit_pass() {
let c = DatConstraints {
rate_limit: Some(RateLimit {
max_actions: 10,
window_secs: 60,
}),
..Default::default()
};
let mut cx = ctx();
cx.actions_in_window = 9;
assert!(c.eval_rate_limit(&cx).is_ok());
}
#[test]
fn test_rate_limit_exceeded() {
let c = DatConstraints {
rate_limit: Some(RateLimit {
max_actions: 10,
window_secs: 60,
}),
..Default::default()
};
let mut cx = ctx();
cx.actions_in_window = 10;
let err = c.eval_rate_limit(&cx).unwrap_err();
assert!(err.to_string().contains("rate limit exceeded"));
}
#[test]
fn test_rate_limit_none() {
let c = DatConstraints::default();
assert!(c.eval_rate_limit(&ctx()).is_ok());
}
#[test]
fn test_ip_allowlist_pass() {
let c = DatConstraints {
ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
..Default::default()
};
let mut cx = ctx();
cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)));
assert!(c.eval_ip_allowlist(&cx).is_ok());
}
#[test]
fn test_ip_allowlist_fail() {
let c = DatConstraints {
ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
..Default::default()
};
let mut cx = ctx();
cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
assert!(c.eval_ip_allowlist(&cx).is_err());
}
#[test]
fn test_ip_allowlist_no_ip_provided() {
let c = DatConstraints {
ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
..Default::default()
};
assert!(c.eval_ip_allowlist(&ctx()).is_err());
}
#[test]
fn test_ip_denylist_blocked() {
let c = DatConstraints {
ip_denylist: Some(vec!["192.168.0.0/16".into()]),
..Default::default()
};
let mut cx = ctx();
cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 5, 10)));
assert!(c.eval_ip_denylist(&cx).is_err());
}
#[test]
fn test_ip_denylist_pass() {
let c = DatConstraints {
ip_denylist: Some(vec!["192.168.0.0/16".into()]),
..Default::default()
};
let mut cx = ctx();
cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
assert!(c.eval_ip_denylist(&cx).is_ok());
}
#[test]
fn test_ip_denylist_no_ip_is_ok() {
let c = DatConstraints {
ip_denylist: Some(vec!["0.0.0.0/0".into()]),
..Default::default()
};
assert!(c.eval_ip_denylist(&ctx()).is_ok());
}
#[test]
fn test_trust_level_pass() {
let c = DatConstraints {
min_trust_level: Some(50),
..Default::default()
};
let mut cx = ctx();
cx.agent_trust_level = Some(75);
assert!(c.eval_trust_level(&cx).is_ok());
}
#[test]
fn test_trust_level_equal_passes() {
let c = DatConstraints {
min_trust_level: Some(80),
..Default::default()
};
let mut cx = ctx();
cx.agent_trust_level = Some(80);
assert!(c.eval_trust_level(&cx).is_ok());
}
#[test]
fn test_trust_level_fail() {
let c = DatConstraints {
min_trust_level: Some(80),
..Default::default()
};
let mut cx = ctx();
cx.agent_trust_level = Some(40);
assert!(c.eval_trust_level(&cx).is_err());
}
#[test]
fn test_trust_level_not_provided() {
let c = DatConstraints {
min_trust_level: Some(1),
..Default::default()
};
assert!(c.eval_trust_level(&ctx()).is_err());
}
#[test]
fn test_delegation_depth_pass() {
let c = DatConstraints {
max_delegation_depth: Some(3),
..Default::default()
};
let mut cx = ctx();
cx.delegation_depth = 2;
assert!(c.eval_delegation_depth(&cx).is_ok());
}
#[test]
fn test_delegation_depth_at_limit() {
let c = DatConstraints {
max_delegation_depth: Some(3),
..Default::default()
};
let mut cx = ctx();
cx.delegation_depth = 3;
assert!(c.eval_delegation_depth(&cx).is_ok());
}
#[test]
fn test_delegation_depth_exceeded() {
let c = DatConstraints {
max_delegation_depth: Some(2),
..Default::default()
};
let mut cx = ctx();
cx.delegation_depth = 3;
assert!(c.eval_delegation_depth(&cx).is_err());
}
#[test]
fn test_delegation_depth_zero_no_redelegate() {
let c = DatConstraints {
max_delegation_depth: Some(0),
..Default::default()
};
let mut cx = ctx();
cx.delegation_depth = 0;
assert!(c.eval_delegation_depth(&cx).is_ok());
cx.delegation_depth = 1;
assert!(c.eval_delegation_depth(&cx).is_err());
}
#[test]
fn test_geofence_pass() {
let c = DatConstraints {
allowed_countries: Some(vec!["AU".into(), "NZ".into()]),
..Default::default()
};
let mut cx = ctx();
cx.country_code = Some("AU".into());
assert!(c.eval_geofence(&cx).is_ok());
}
#[test]
fn test_geofence_case_insensitive() {
let c = DatConstraints {
allowed_countries: Some(vec!["AU".into()]),
..Default::default()
};
let mut cx = ctx();
cx.country_code = Some("au".into());
assert!(c.eval_geofence(&cx).is_ok());
}
#[test]
fn test_geofence_fail() {
let c = DatConstraints {
allowed_countries: Some(vec!["AU".into()]),
..Default::default()
};
let mut cx = ctx();
cx.country_code = Some("US".into());
assert!(c.eval_geofence(&cx).is_err());
}
#[test]
fn test_geofence_no_country_code() {
let c = DatConstraints {
allowed_countries: Some(vec!["AU".into()]),
..Default::default()
};
assert!(c.eval_geofence(&ctx()).is_err());
}
#[test]
fn test_time_window_pass() {
let ts = 1705327800_i64; let c = DatConstraints {
time_windows: Some(vec![TimeWindow {
start_hour: 9,
end_hour: 17,
days_of_week: None,
}]),
..Default::default()
};
let mut cx = ctx();
cx.current_timestamp = Some(ts);
assert!(c.eval_time_windows(&cx).is_ok());
}
#[test]
fn test_time_window_fail_outside_hours() {
let ts = 1705276800_i64;
let c = DatConstraints {
time_windows: Some(vec![TimeWindow {
start_hour: 9,
end_hour: 17,
days_of_week: None,
}]),
..Default::default()
};
let mut cx = ctx();
cx.current_timestamp = Some(ts);
assert!(c.eval_time_windows(&cx).is_err());
}
#[test]
fn test_time_window_day_of_week_pass() {
let ts = 1705327800_i64;
let c = DatConstraints {
time_windows: Some(vec![TimeWindow {
start_hour: 9,
end_hour: 17,
days_of_week: Some(vec![0, 1, 2, 3, 4]), }]),
..Default::default()
};
let mut cx = ctx();
cx.current_timestamp = Some(ts);
assert!(c.eval_time_windows(&cx).is_ok());
}
#[test]
fn test_time_window_day_of_week_fail() {
let ts = 1705759200_i64;
let c = DatConstraints {
time_windows: Some(vec![TimeWindow {
start_hour: 9,
end_hour: 17,
days_of_week: Some(vec![0, 1, 2, 3, 4]), }]),
..Default::default()
};
let mut cx = ctx();
cx.current_timestamp = Some(ts);
assert!(c.eval_time_windows(&cx).is_err());
}
#[test]
fn test_time_window_wraparound() {
let ts = 1705363200_i64;
let c = DatConstraints {
time_windows: Some(vec![TimeWindow {
start_hour: 22,
end_hour: 2,
days_of_week: None,
}]),
..Default::default()
};
let mut cx = ctx();
cx.current_timestamp = Some(ts);
assert!(c.eval_time_windows(&cx).is_ok());
}
#[test]
fn test_config_attestation_pass() {
let hash = "abc123def456".to_string();
let c = DatConstraints {
required_config_hash: Some(hash.clone()),
..Default::default()
};
let mut cx = ctx();
cx.agent_config_hash = Some(hash.clone());
assert!(c.eval_config_attestation(&cx, Some(&hash)).is_ok());
}
#[test]
fn test_config_attestation_token_mismatch() {
let c = DatConstraints {
required_config_hash: Some("required_hash".into()),
..Default::default()
};
let mut cx = ctx();
cx.agent_config_hash = Some("required_hash".into());
assert!(c.eval_config_attestation(&cx, Some("other_hash")).is_err());
}
#[test]
fn test_config_attestation_live_mismatch() {
let c = DatConstraints {
required_config_hash: Some("required_hash".into()),
..Default::default()
};
let mut cx = ctx();
cx.agent_config_hash = Some("different_hash".into());
assert!(c
.eval_config_attestation(&cx, Some("required_hash"))
.is_err());
}
#[test]
fn test_config_attestation_no_token_claim() {
let c = DatConstraints {
required_config_hash: Some("required_hash".into()),
..Default::default()
};
let mut cx = ctx();
cx.agent_config_hash = Some("required_hash".into());
assert!(c.eval_config_attestation(&cx, None).is_err());
}
#[test]
fn test_config_attestation_no_constraint() {
let c = DatConstraints::default();
assert!(c.eval_config_attestation(&ctx(), None).is_ok());
}
#[test]
fn test_evaluate_all_pass() {
let c = DatConstraints {
rate_limit: Some(RateLimit {
max_actions: 100,
window_secs: 60,
}),
ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
ip_denylist: Some(vec!["10.0.0.0/24".into()]), min_trust_level: Some(50),
max_delegation_depth: Some(3),
allowed_countries: Some(vec!["AU".into()]),
..Default::default()
};
let mut cx = ctx();
cx.actions_in_window = 5;
cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 0, 1))); cx.agent_trust_level = Some(75);
cx.delegation_depth = 2;
cx.country_code = Some("AU".into());
assert!(c.evaluate(&cx).is_ok());
}
#[test]
fn test_evaluate_stops_at_first_violation() {
let c = DatConstraints {
rate_limit: Some(RateLimit {
max_actions: 1,
window_secs: 60,
}),
min_trust_level: Some(99), ..Default::default()
};
let mut cx = ctx();
cx.actions_in_window = 5; cx.agent_trust_level = Some(10);
let err = c.evaluate(&cx).unwrap_err().to_string();
assert!(err.contains("rate limit exceeded"));
}
}