use crate::audit::AuditSink;
use crate::contracts::{MAX_IDENTIFIER_BYTES, validate_request};
use crate::crypto::{IssuerKeyResolver, verify_token};
use crate::models::{AuditEvent, Decision, OperationContext, RequestEnvelope, Violation};
use crate::revocation::TrustStateStore;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value;
use std::io;
pub const DEFAULT_MAX_ENVELOPE_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct EvaluationConfig {
pub clock_leeway: Duration,
pub max_token_lifetime: Duration,
pub max_envelope_bytes: usize,
}
impl Default for EvaluationConfig {
fn default() -> Self {
Self {
clock_leeway: Duration::seconds(30),
max_token_lifetime: Duration::hours(24),
max_envelope_bytes: DEFAULT_MAX_ENVELOPE_BYTES,
}
}
}
pub struct Evaluator<'a> {
issuer_keys: &'a dyn IssuerKeyResolver,
trust_state: &'a dyn TrustStateStore,
config: EvaluationConfig,
}
impl<'a> Evaluator<'a> {
pub fn new(
issuer_keys: &'a dyn IssuerKeyResolver,
trust_state: &'a dyn TrustStateStore,
) -> Self {
Self {
issuer_keys,
trust_state,
config: EvaluationConfig::default(),
}
}
pub fn with_config(mut self, config: EvaluationConfig) -> Result<Self, Violation> {
if config.clock_leeway < Duration::zero() || config.clock_leeway > Duration::minutes(5) {
return Err(Violation::new(
"configuration",
"clock_leeway must be between zero and five minutes",
));
}
if config.max_token_lifetime <= Duration::zero() {
return Err(Violation::new(
"configuration",
"max_token_lifetime must be positive",
));
}
if config.max_envelope_bytes == 0 {
return Err(Violation::new(
"configuration",
"max_envelope_bytes must be positive",
));
}
self.config = config;
Ok(self)
}
pub fn evaluate(
&self,
raw_request: &[u8],
operation: &OperationContext,
now: DateTime<Utc>,
) -> (Decision, AuditEvent) {
let result = self.evaluate_inner(raw_request, operation, now);
match result {
Ok(envelope) => {
let decision = Decision::allow("authorized", "capability authorized operation");
let event = audit_from_envelope(&envelope, operation, now, &decision);
(decision, event)
}
Err(violation) => {
let decision = Decision::deny(violation.stage, violation.reason.clone());
let event = audit_from_raw(
raw_request,
operation,
now,
&decision,
self.config.max_envelope_bytes,
);
(decision, event)
}
}
}
pub fn evaluate_and_audit(
&self,
raw_request: &[u8],
operation: &OperationContext,
now: DateTime<Utc>,
sink: &dyn AuditSink,
) -> io::Result<Decision> {
let (decision, event) = self.evaluate(raw_request, operation, now);
sink.write_event(&event)?;
Ok(decision)
}
fn evaluate_inner(
&self,
raw_request: &[u8],
operation: &OperationContext,
now: DateTime<Utc>,
) -> Result<RequestEnvelope, Violation> {
validate_operation(operation)?;
if raw_request.len() > self.config.max_envelope_bytes {
return Err(Violation::new(
"normalize_request",
format!(
"request exceeds the configured {} byte limit",
self.config.max_envelope_bytes
),
));
}
let envelope: RequestEnvelope = serde_json::from_slice(raw_request).map_err(|error| {
Violation::new(
"normalize_request",
format!("request does not match the contract: {error}"),
)
})?;
validate_request(&envelope)?;
let token = &envelope.delegation_token;
verify_token(token, self.issuer_keys)?;
if token.issued_at > now + self.config.clock_leeway {
return Err(Violation::new(
"validate_lifetime",
"token is not active yet",
));
}
if token.expires_at <= now - self.config.clock_leeway {
return Err(Violation::new("validate_lifetime", "token has expired"));
}
if token.expires_at - token.issued_at > self.config.max_token_lifetime {
return Err(Violation::new(
"validate_lifetime",
"token lifetime exceeds the configured maximum",
));
}
let revoked = self
.trust_state
.is_token_revoked(&token.issuer, &token.token_id)
.map_err(|error| state_error("token revocation lookup", error))?;
if revoked {
return Err(Violation::new("revocation", "token is revoked"));
}
let denied = self
.trust_state
.is_agent_denied(&token.issuer, &token.agent_id)
.map_err(|error| state_error("agent deny lookup", error))?;
if denied {
return Err(Violation::new("revocation", "agent is denied"));
}
bind_operation(token, operation)?;
let fresh = self
.trust_state
.consume_nonce(&token.issuer, &token.nonce, now, token.expires_at)
.map_err(|error| state_error("nonce consumption", error))?;
if !fresh {
return Err(Violation::new(
"replay",
"token nonce has already been consumed",
));
}
Ok(envelope)
}
}
fn validate_operation(operation: &OperationContext) -> Result<(), Violation> {
if !valid_operation_value(&operation.audience) || !valid_operation_value(&operation.action) {
return Err(Violation::new(
"operation_context",
format!(
"host audience and action must be trimmed and contain 1..={MAX_IDENTIFIER_BYTES} bytes"
),
));
}
if operation
.resource
.as_ref()
.is_some_and(|value| !valid_operation_value(value))
{
return Err(Violation::new(
"operation_context",
format!("host resource must be trimmed and contain 1..={MAX_IDENTIFIER_BYTES} bytes"),
));
}
Ok(())
}
fn valid_operation_value(value: &str) -> bool {
!value.is_empty() && value.len() <= MAX_IDENTIFIER_BYTES && value.trim().len() == value.len()
}
fn bind_operation(
token: &crate::models::DelegationToken,
operation: &OperationContext,
) -> Result<(), Violation> {
if !token.audience.contains(&operation.audience) {
return Err(Violation::new(
"bind_operation",
"audience is not authorized",
));
}
if !token.allowed_actions.contains(&operation.action) {
return Err(Violation::new("bind_operation", "action is not authorized"));
}
if let Some(allowed) = &token.allowed_resources {
let resource = operation.resource.as_ref().ok_or_else(|| {
Violation::new(
"bind_operation",
"host resource is required by this constrained token",
)
})?;
if !allowed.contains(resource) {
return Err(Violation::new(
"bind_operation",
"resource is not authorized",
));
}
}
if let Some(maximum) = token.max_delegation_depth {
let actual = operation.delegation_depth.ok_or_else(|| {
Violation::new(
"bind_operation",
"host delegation depth is required by this constrained token",
)
})?;
if actual > maximum {
return Err(Violation::new(
"bind_operation",
"delegation depth exceeds the token maximum",
));
}
}
Ok(())
}
fn state_error(operation: &str, error: impl std::fmt::Display) -> Violation {
Violation::new(
"trust_state",
format!("{operation} failed; denying request: {error}"),
)
}
fn audit_from_envelope(
envelope: &RequestEnvelope,
operation: &OperationContext,
now: DateTime<Utc>,
decision: &Decision,
) -> AuditEvent {
let token = &envelope.delegation_token;
AuditEvent {
occurred_at: now,
allowed: decision.allowed,
stage: decision.stage.clone(),
reason: decision.reason.clone(),
claims_verified: true,
request_id: envelope.request_id.clone(),
token_id: Some(token.token_id.clone()),
issuer: Some(token.issuer.clone()),
agent_id: Some(token.agent_id.clone()),
delegator_id: Some(token.delegator_id.clone()),
audience: operation.audience.clone(),
action: operation.action.clone(),
resource: operation.resource.clone(),
}
}
fn audit_from_raw(
raw: &[u8],
operation: &OperationContext,
now: DateTime<Utc>,
decision: &Decision,
max_envelope_bytes: usize,
) -> AuditEvent {
let parsed: Option<Value> = (raw.len() <= max_envelope_bytes)
.then(|| serde_json::from_slice(raw).ok())
.flatten();
let token = parsed
.as_ref()
.and_then(|value| value.get("delegation_token"));
AuditEvent {
occurred_at: now,
allowed: false,
stage: decision.stage.clone(),
reason: decision.reason.clone(),
claims_verified: false,
request_id: parsed
.as_ref()
.and_then(|value| string_at(value, "request_id")),
token_id: token.and_then(|value| string_at(value, "token_id")),
issuer: token.and_then(|value| string_at(value, "issuer")),
agent_id: token.and_then(|value| string_at(value, "agent_id")),
delegator_id: token.and_then(|value| string_at(value, "delegator_id")),
audience: operation.audience.clone(),
action: operation.action.clone(),
resource: operation.resource.clone(),
}
}
fn string_at(value: &Value, key: &str) -> Option<String> {
value.get(key)?.as_str().map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone, Utc};
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, 0).single().unwrap()
}
#[test]
fn evaluation_config_rejects_unsafe_limits() {
let keys = crate::crypto::PinnedIssuerKeys::new();
let state = crate::revocation::InMemoryTrustState::new();
let too_much_leeway = EvaluationConfig {
clock_leeway: Duration::minutes(6),
..EvaluationConfig::default()
};
assert!(
Evaluator::new(&keys, &state)
.with_config(too_much_leeway)
.is_err()
);
let zero_lifetime = EvaluationConfig {
max_token_lifetime: Duration::zero(),
..EvaluationConfig::default()
};
assert!(
Evaluator::new(&keys, &state)
.with_config(zero_lifetime)
.is_err()
);
let zero_bytes = EvaluationConfig {
max_envelope_bytes: 0,
..EvaluationConfig::default()
};
assert!(
Evaluator::new(&keys, &state)
.with_config(zero_bytes)
.is_err()
);
}
#[test]
fn validate_operation_rejects_blank_or_untrimmed_values() {
assert!(validate_operation(&OperationContext::new("", "calendar.create")).is_err());
assert!(validate_operation(&OperationContext::new("calendar-api", "")).is_err());
assert!(
validate_operation(
&OperationContext::new("calendar-api", "calendar.create").with_resource(" bad")
)
.is_err()
);
}
#[test]
fn audit_from_raw_marks_claims_unverified_and_preserves_operation() {
let operation = OperationContext::new("calendar-api", "calendar.create")
.with_resource("calendar:alice");
let decision = Decision::deny("normalize_request", "bad request");
let raw = br#"{"request_id":"req-1","delegation_token":{"token_id":"tok-1","issuer":"issuer.example","agent_id":"agent:bad","delegator_id":"user:bob"}}"#;
let event = audit_from_raw(raw, &operation, now(), &decision, 1024);
assert!(!event.allowed);
assert!(!event.claims_verified);
assert_eq!(event.request_id.as_deref(), Some("req-1"));
assert_eq!(event.token_id.as_deref(), Some("tok-1"));
assert_eq!(event.audience, "calendar-api");
assert_eq!(event.resource.as_deref(), Some("calendar:alice"));
}
}