delegated 0.2.0

Minimal fail-closed capability-token evaluation core
Documentation
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)
    }

    /// Evaluates an untrusted envelope against the operation the host will execute.
    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)
            }
        }
    }

    /// Evaluation plus mandatory audit persistence. An audit write error is returned and
    /// callers must not execute the operation.
    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;

        // Trust is established exclusively through a host-configured issuer key.
        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)?;

        // Consume only after all other checks pass. Invalid or unauthorized requests cannot
        // burn a valid nonce. The backend operation itself must be atomic.
        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)
}