agentguard_server/
authzen.rs

1//! AuthZEN HTTP endpoint types and handlers.
2//!
3//! Reference: <https://openid.github.io/authzen/> (OpenID AuthZEN WG draft).
4
5use crate::audit::{AsyncAuditAppender, AuditAppender, AuditWriter};
6use agentguard_core::authorize::entities::build_entities;
7use agentguard_core::decision::{
8    cache::CacheConfig, AuthenticatedActor, DecisionLog, RotationConfig,
9};
10use agentguard_core::observability::TraceContext;
11use agentguard_core::{AgentRequest, Authorizer, Effect, PolicyStore};
12use agentguard_telemetry::Metrics;
13use axum::extract::{Extension, Request, State};
14use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
15use axum::middleware::{from_fn, from_fn_with_state, Next};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{get, post};
18use axum::{Json, Router};
19use cedar_policy::Entities;
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22use std::sync::{Arc, RwLock};
23use std::time::{Duration, Instant};
24use tokio::sync::Semaphore;
25
26/// Bound policy evaluation and audit fsync work so they cannot block Tokio
27/// workers or create an unbounded blocking-task queue.
28const MAX_CONCURRENT_PDP_WORK_ITEMS: usize = 4;
29const AUDIT_READINESS_TIMEOUT: Duration = Duration::from_secs(2);
30const AUDIT_APPEND_TIMEOUT: Duration = Duration::from_secs(5);
31
32#[derive(Debug)]
33pub(crate) enum PdpWorkError {
34    Saturated,
35    Join(tokio::task::JoinError),
36    Entities(String),
37    Authorize(agentguard_core::Error),
38    Audit(agentguard_core::Error),
39}
40
41pub(crate) fn report_pdp_work_failure(
42    state: &AppState,
43    error: PdpWorkError,
44) -> (StatusCode, &'static str) {
45    match error {
46        PdpWorkError::Saturated => {
47            state.metrics().record_pdp_error("pdp_overloaded");
48            (StatusCode::SERVICE_UNAVAILABLE, "PDP capacity exhausted")
49        }
50        PdpWorkError::Join(error) => {
51            state.metrics().record_pdp_error("pdp_worker");
52            tracing::error!(%error, "PDP worker failed; refusing decision");
53            (
54                StatusCode::INTERNAL_SERVER_ERROR,
55                "authorization unavailable",
56            )
57        }
58        PdpWorkError::Entities(error) => {
59            state.metrics().record_pdp_error("entities_build");
60            tracing::debug!(%error, "request entities rejected");
61            (StatusCode::BAD_REQUEST, "invalid request entities")
62        }
63        PdpWorkError::Authorize(error) => {
64            state.metrics().record_pdp_error("authorize");
65            let (code, summary) = summarize_authorize_error(&error);
66            tracing::error!(error_code = %code, error = %summary, "authorize failed");
67            tracing::debug!(error = ?error, "full authorize error");
68            (
69                StatusCode::INTERNAL_SERVER_ERROR,
70                "internal authorization error",
71            )
72        }
73        PdpWorkError::Audit(error) => {
74            state.metrics().record_pdp_error("audit_append");
75            tracing::error!(%error, "audit append failed; refusing decision");
76            (StatusCode::INTERNAL_SERVER_ERROR, "audit log unavailable")
77        }
78    }
79}
80
81pub(crate) async fn authorize_and_persist_decision(
82    slots: Arc<Semaphore>,
83    authorizer: Arc<Authorizer>,
84    request: AgentRequest,
85    entity_values: Vec<serde_json::Value>,
86    audit: Arc<Option<AuditWriter>>,
87    authenticated_actor: Option<AuthenticatedActor>,
88) -> Result<(agentguard_core::Decision, std::time::Duration), PdpWorkError> {
89    let ((decision, elapsed), permit) = run_bounded_pdp_work(slots, move || {
90        let entities = build_request_entities(&entity_values).map_err(PdpWorkError::Entities)?;
91        let started = Instant::now();
92        let mut decision = authorizer
93            .authorize(&request, &entities)
94            .map_err(PdpWorkError::Authorize)?;
95        if let Some(actor) = authenticated_actor {
96            if let serde_json::Value::Object(request) = &mut decision.request {
97                request.insert(
98                    "authenticated_actor".into(),
99                    serde_json::to_value(actor).expect("authenticated actor is serializable"),
100                );
101            }
102        }
103        let elapsed = started.elapsed();
104        Ok((decision, elapsed))
105    })
106    .await?;
107
108    match audit.as_ref() {
109        Some(AuditWriter::Sync(appender)) => {
110            let appender = appender.clone();
111            tokio::task::spawn_blocking(move || {
112                let _permit = permit;
113                appender
114                    .append_decision(&decision)
115                    .map_err(PdpWorkError::Audit)?;
116                Ok((decision, elapsed))
117            })
118            .await
119            .map_err(PdpWorkError::Join)?
120        }
121        Some(AuditWriter::Async(appender)) => {
122            let _permit = permit;
123            tokio::time::timeout(AUDIT_APPEND_TIMEOUT, appender.append_decision(&decision))
124                .await
125                .map_err(|_| {
126                    PdpWorkError::Audit(agentguard_core::Error::Io("audit append timed out".into()))
127                })?
128                .map_err(PdpWorkError::Audit)?;
129            Ok((decision, elapsed))
130        }
131        None => {
132            drop(permit);
133            Ok((decision, elapsed))
134        }
135    }
136}
137
138pub(crate) fn authenticated_actor(
139    caller: Option<&crate::auth_layer::AuthenticatedIdentity>,
140) -> Option<AuthenticatedActor> {
141    caller.map(|caller| AuthenticatedActor {
142        subject_type: caller.identity.subject_type.clone(),
143        subject_id: caller.identity.subject_id.clone(),
144        tenant_id: caller.identity.tenant_id.clone(),
145        credential_id: caller.credential_id.clone(),
146        can_act_as: caller.can_act_as,
147    })
148}
149
150async fn run_bounded_pdp_work<T, F>(
151    slots: Arc<Semaphore>,
152    operation: F,
153) -> Result<(T, tokio::sync::OwnedSemaphorePermit), PdpWorkError>
154where
155    T: Send + 'static,
156    F: FnOnce() -> Result<T, PdpWorkError> + Send + 'static,
157{
158    let permit = slots
159        .try_acquire_owned()
160        .map_err(|_| PdpWorkError::Saturated)?;
161    let (result, permit) = tokio::task::spawn_blocking(move || (operation(), permit))
162        .await
163        .map_err(PdpWorkError::Join)?;
164    result.map(|value| (value, permit))
165}
166
167/// Maximum number of evaluations accepted in a single
168/// `/access/v1/evaluations` request. Caps memory + CPU per request;
169/// anything larger should be split by the caller.
170pub const MAX_BATCH_EVALUATIONS: usize = 100;
171
172/// OpenID AuthZEN EvaluationRequest (subject/action/resource/context).
173///
174/// Per the AuthZEN draft, requests MAY include an `entities` array of
175/// fully-formed Cedar entity JSON objects (`{uid: {type, id}, attrs: {...},
176/// parents: [...]}`). When present they are unioned with the always-present
177/// subject/action/resource entities and passed to the Cedar evaluator.
178///
179/// Without `entities`, every real-world policy that references any entity
180/// attribute or hierarchy (the typical case) returns Deny because Cedar
181/// resolves `principal in Group::"admins"` against an empty store. This
182/// is the single most common AuthZEN integration bug.
183#[derive(Debug, Deserialize, Serialize)]
184pub struct EvaluationRequest {
185    pub subject: EntityRef,
186    pub action: EntityRef,
187    pub resource: EntityRef,
188    #[serde(default)]
189    pub context: serde_json::Value,
190    /// Optional list of entity JSON objects to make available to the
191    /// Cedar evaluator. See module docs.
192    #[serde(default)]
193    pub entities: Vec<serde_json::Value>,
194}
195
196/// Subject/Action/Resource reference: a single entity.
197#[derive(Debug, Deserialize, Serialize)]
198pub struct EntityRef {
199    #[serde(rename = "type")]
200    pub entity_type: String,
201    pub id: String,
202}
203
204/// AuthZEN EvaluationResponse.
205#[derive(Debug, Serialize, Deserialize)]
206pub struct EvaluationResponse {
207    /// `true` = allow, `false` = deny.
208    pub decision: bool,
209    /// Optional context to return to the PEP (e.g. acr_values for step-up).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub context: Option<serde_json::Value>,
212    /// Optional reason.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub reason: Option<String>,
215}
216
217/// Batch evaluation semantics.
218#[derive(Debug, Deserialize, Serialize, Default)]
219#[serde(rename_all = "snake_case")]
220pub enum EvaluationSemantics {
221    /// Run every evaluation regardless of decisions.
222    #[default]
223    ExecuteAll,
224    /// Stop and deny on first deny.
225    DenyOnFirstDeny,
226    /// Stop and permit on first permit.
227    PermitOnFirstPermit,
228}
229
230#[derive(Debug, Deserialize, Serialize)]
231pub struct BatchEvaluationRequest {
232    pub evaluations: Vec<EvaluationRequest>,
233    #[serde(default)]
234    pub evaluation_semantics: Option<EvaluationSemantics>,
235    #[serde(default)]
236    pub subject: Option<EntityRef>,
237    #[serde(default)]
238    pub resource: Option<EntityRef>,
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242pub struct BatchEvaluationResponse {
243    pub evaluations: Vec<EvaluationResponse>,
244}
245
246/// Shared state for HTTP handlers.
247///
248/// `authorizer` and `audit` are private with public accessors
249/// (`authorizer()`, `audit()`). The authorizer handle atomically swaps
250/// policy snapshots when the watcher observes a valid change, so in-flight
251/// requests finish against the snapshot they started with.
252#[derive(Clone)]
253pub struct AppState {
254    authorizer: AuthorizerHandle,
255    /// Audit log writer. Every authorization decision is appended
256    /// here. `None` only when the operator explicitly opts out (the
257    /// CLI `--skip-audit` flag).
258    audit: Arc<Option<Arc<DecisionLog>>>,
259    audit_appender: Arc<Option<AuditWriter>>,
260    /// Authentication layer. `Disabled` allows any caller; `ApiKey`
261    /// validates `Authorization: Bearer <raw>`.
262    pub auth: crate::auth_layer::AuthLayer,
263    /// Metrics registry. Always populated; even with no exporter
264    /// wired, `/metrics` returns the current snapshot.
265    pub metrics: Arc<Metrics>,
266    /// Bounds blocking policy/audit work for this app instance. Cloned state
267    /// shares the limit; independent embedded routers remain isolated.
268    pub(crate) pdp_work_slots: Arc<Semaphore>,
269}
270
271/// Runtime dependencies and tuning for an [`AppState`].
272///
273/// Embedded applications pass this explicitly so state construction does not
274/// depend on ambient process environment. The standalone server translates its
275/// environment configuration into this value at the application boundary.
276#[derive(Debug, Clone, Default)]
277pub struct AppStateOptions {
278    /// Decision-cache configuration. `None` disables caching.
279    pub cache: Option<CacheConfig>,
280    /// Optional audit-log rotation threshold.
281    pub audit_rotation: Option<RotationConfig>,
282}
283
284/// Concurrent policy snapshot with an explicit reload boundary.
285///
286/// Requests take an `Arc` snapshot for the duration of one evaluation while
287/// reload builds a complete replacement off to the side. A malformed policy
288/// therefore leaves the last known-good snapshot serving instead of replacing
289/// it with partial state.
290#[derive(Clone)]
291pub struct AuthorizerHandle {
292    current: Arc<RwLock<Arc<Authorizer>>>,
293    store_root: Arc<PathBuf>,
294    cache: Option<CacheConfig>,
295}
296
297impl AuthorizerHandle {
298    fn new(store_root: PathBuf, cache: Option<CacheConfig>) -> Result<Self, String> {
299        let authorizer = Self::load(&store_root, cache.as_ref())?;
300        Ok(Self {
301            current: Arc::new(RwLock::new(Arc::new(authorizer))),
302            store_root: Arc::new(store_root),
303            cache,
304        })
305    }
306
307    fn load(store_root: &PathBuf, cache: Option<&CacheConfig>) -> Result<Authorizer, String> {
308        let store = PolicyStore::open(store_root).map_err(|e| format!("open store: {}", e))?;
309        let mut authorizer = Authorizer::new(store).map_err(|e| format!("authorizer: {}", e))?;
310        if let Some(cfg) = cache {
311            authorizer = authorizer
312                .try_with_cache(cfg.clone())
313                .map_err(|error| format!("decision cache configuration: {error}"))?;
314        }
315        Ok(authorizer)
316    }
317
318    pub(crate) fn snapshot(&self) -> Arc<Authorizer> {
319        self.current
320            .read()
321            .expect("authorizer snapshot lock poisoned")
322            .clone()
323    }
324
325    /// Replace the serving snapshot only after the complete policy store has
326    /// loaded successfully. The old snapshot remains available on failure.
327    pub fn reload(&self) -> Result<(), String> {
328        let replacement = Arc::new(Self::load(&self.store_root, self.cache.as_ref())?);
329        let mut current = self
330            .current
331            .write()
332            .map_err(|_| "authorizer reload lock poisoned".to_string())?;
333        *current = replacement;
334        Ok(())
335    }
336
337    pub fn authorize(
338        &self,
339        req: &AgentRequest,
340        entities: &Entities,
341    ) -> agentguard_core::Result<agentguard_core::Decision> {
342        self.snapshot().authorize(req, entities)
343    }
344
345    pub fn policy_count(&self) -> usize {
346        self.snapshot().policy_count()
347    }
348
349    pub fn invalidate_cache(&self) {
350        self.snapshot().invalidate_cache();
351    }
352
353    pub fn sweep_stale_cache(&self) -> usize {
354        self.snapshot().sweep_stale_cache()
355    }
356}
357
358impl AppState {
359    /// Replace the configured audit writer with an embedder-provided durable
360    /// adapter. Use this with `build_state` and `audit_log: None` when audit
361    /// persistence is managed outside the local filesystem.
362    ///
363    /// Readiness requires the adapter to report both healthy and chained.
364    /// Every successful authorization is returned only after the adapter
365    /// confirms its append; append failures fail closed.
366    pub fn with_audit_appender(mut self, appender: Arc<dyn AuditAppender>) -> Self {
367        self.audit_appender = Arc::new(Some(AuditWriter::Sync(appender)));
368        self
369    }
370
371    /// Inject an async durable audit adapter. The per-state PDP work permit
372    /// remains held until persistence completes, applying bounded backpressure
373    /// without occupying a Tokio blocking worker during remote I/O.
374    pub fn with_async_audit_appender(mut self, appender: Arc<dyn AsyncAuditAppender>) -> Self {
375        self.audit_appender = Arc::new(Some(AuditWriter::Async(appender)));
376        self
377    }
378
379    /// The authorization engine. Cheap to clone (already an `Arc`).
380    pub fn authorizer(&self) -> &AuthorizerHandle {
381        &self.authorizer
382    }
383
384    /// The audit log writer, if configured. `None` when the operator
385    /// disabled audit logging.
386    pub fn audit(&self) -> Option<&DecisionLog> {
387        self.audit.as_ref().as_deref()
388    }
389
390    pub(crate) fn audit_handle(&self) -> Arc<Option<AuditWriter>> {
391        self.audit_appender.clone()
392    }
393
394    /// The metrics registry. The same handle is used by `/metrics`,
395    /// the OTLP sink (when enabled), and the in-handler counters.
396    pub fn metrics(&self) -> &Arc<Metrics> {
397        &self.metrics
398    }
399}
400
401/// Build the AuthZEN HTTP router.
402///
403/// The router exposes:
404/// - `POST /access/v1/evaluation` — single decision
405/// - `POST /access/v1/evaluations` — batch with
406///   `evaluation_semantics: "execute_all" | "deny_on_first_deny" |
407///   "permit_on_first_permit"`
408/// - `GET /healthz` — always 200
409/// - `GET /readyz` — 200 only if policies are loaded AND the audit log
410///   is writable
411///
412/// The body is capped at 64 KB; larger requests are rejected by
413/// axum's `DefaultBodyLimit` layer.
414pub fn router(state: AppState) -> Router {
415    Router::new()
416        .route("/access/v1/evaluation", post(evaluation))
417        .route("/access/v1/evaluations", post(evaluations))
418        .route("/healthz", get(healthz))
419        .route("/readyz", get(readyz))
420        .route("/metrics", get(metrics))
421        // Auth runs after trace context (so unauthorized requests still
422        // get a span id echoed back), and after the body limit (so we
423        // don't buffer megabytes before 401-ing).
424        .layer(from_fn_with_state(
425            state.clone(),
426            crate::auth_layer::auth_layer_fn,
427        ))
428        // Cap request bodies at 64 KB. AuthZEN requests are small JSON; anything
429        // larger is either misconfigured or an attack.
430        .layer(axum::extract::DefaultBodyLimit::max(64 * 1024))
431        // Inject/propagate W3C Trace Context for every request. If the
432        // caller sent a `traceparent` header, we honor it; otherwise we
433        // generate a fresh root span. The span id is added to every
434        // response as the `x-agentguard-span-id` header so callers can
435        // correlate logs and decisions.
436        .layer(from_fn(trace_context_layer))
437        .with_state(state)
438}
439
440/// Prometheus-text snapshot of every metric the server has recorded.
441async fn metrics(State(state): State<AppState>) -> Response {
442    (
443        StatusCode::OK,
444        [(
445            axum::http::header::CONTENT_TYPE,
446            "text/plain; version=0.0.4",
447        )],
448        state.metrics().render_prometheus(),
449    )
450        .into_response()
451}
452
453/// W3C Trace Context middleware: read incoming `traceparent` or generate
454/// a fresh trace, and echo the span id back to the caller.
455async fn trace_context_layer(headers: HeaderMap, mut req: Request, next: Next) -> Response {
456    let traceparent = headers
457        .get("traceparent")
458        .and_then(|v| v.to_str().ok())
459        .and_then(|s| s.parse::<TraceContext>().ok());
460    let trace = traceparent.unwrap_or_else(TraceContext::fresh);
461    // Generate a child span for this request handling hop.
462    let child = trace.child();
463    let span_id = child.span_id;
464
465    // Stash the parsed trace in request extensions so handlers can use it
466    // (e.g. to attach to a DecisionRecord).
467    req.extensions_mut().insert(child);
468
469    let mut response = next.run(req).await;
470    if let Ok(v) = HeaderValue::from_str(&span_id.to_string()) {
471        response
472            .headers_mut()
473            .insert(HeaderName::from_static("x-agentguard-span-id"), v);
474    }
475    response
476}
477
478async fn healthz() -> &'static str {
479    "ok\n"
480}
481
482async fn readyz(State(state): State<AppState>) -> Response {
483    // 1. Policies must be loaded. policy_count() is O(1) (the
484    // cedar PolicySet length is cached), unlike the previous
485    // .policies().next().is_some() which walked the full set.
486    if state.authorizer.policy_count() == 0 {
487        return readyz_unavailable("policies not loaded");
488    }
489    // 2. Audit log must be configured, have a chained identity, and retain
490    // an active writer handle. A permanent append failure poisons that handle
491    // so the orchestrator can stop routing new decisions to this instance.
492    match state.audit_appender.as_ref() {
493        Some(AuditWriter::Sync(audit)) if !audit.is_healthy() => {
494            return readyz_unavailable("audit log unavailable")
495        }
496        Some(AuditWriter::Sync(audit)) if audit.is_chained() => (),
497        Some(AuditWriter::Async(audit)) => {
498            let healthy = tokio::time::timeout(AUDIT_READINESS_TIMEOUT, audit.is_healthy())
499                .await
500                .unwrap_or(false);
501            if !healthy {
502                return readyz_unavailable("audit log unavailable");
503            }
504            let chained = tokio::time::timeout(AUDIT_READINESS_TIMEOUT, audit.is_chained())
505                .await
506                .unwrap_or(false);
507            if !chained {
508                return readyz_unavailable("audit log not opened");
509            }
510        }
511        Some(_) => return readyz_unavailable("audit log not opened"),
512        None => return readyz_unavailable("audit log not configured"),
513    }
514    (StatusCode::OK, "ok\n").into_response()
515}
516
517fn readyz_unavailable(reason: &str) -> Response {
518    (StatusCode::SERVICE_UNAVAILABLE, format!("{reason}\n")).into_response()
519}
520
521/// Sanitize an authorization error for logging. Returns a stable
522/// error code plus a short summary that does NOT include the
523/// underlying cedar policy / schema text. The full error is kept
524/// for a separate debug-level log line so engineering can still
525/// investigate without sending it to log aggregators.
526fn summarize_authorize_error(e: &agentguard_core::Error) -> (&'static str, String) {
527    use agentguard_core::Error;
528    match e {
529        Error::Io(_) => ("io", "io error".to_string()),
530        Error::Json(_) => ("json", "json error".to_string()),
531        Error::Schema(_) => ("schema", "schema error".to_string()),
532        Error::InvalidPrincipal(_) => ("invalid_principal", "invalid principal".to_string()),
533        Error::InvalidResource(_) => ("invalid_resource", "invalid resource".to_string()),
534        Error::InvalidContext(_) => ("invalid_context", "invalid context".to_string()),
535        Error::PolicyParse { .. } => ("policy_parse", "policy parse error".to_string()),
536        Error::Validation(_) => ("validation", "policy validation failed".to_string()),
537        Error::Entities(_) => ("entities", "entities build failed".to_string()),
538        Error::Walk(_) => ("walk", "policy walk failed".to_string()),
539        Error::Other(_) => ("other", "internal error".to_string()),
540        // Token variants are surfaced by the auth layer, not the
541        // PDP, but be exhaustive just in case.
542        Error::InvalidToken(_) => ("invalid_token", "invalid delegation token".to_string()),
543        Error::TokenExpired(_) => ("token_expired", "delegation token expired".to_string()),
544        Error::TokenRevoked(_) => ("token_revoked", "delegation token revoked".to_string()),
545        Error::TokenSignature { .. } => (
546            "token_signature",
547            "delegation signature invalid".to_string(),
548        ),
549        Error::TokenNotYetValid(_) => (
550            "token_not_yet_valid",
551            "delegation token not yet valid".to_string(),
552        ),
553        _ => ("other", "internal error".to_string()),
554    }
555}
556
557pub fn evaluation_request_to_agent(req: EvaluationRequest) -> Result<AgentRequest, String> {
558    let principal = match req.subject.entity_type.as_str() {
559        "User" => agentguard_core::Principal::user(req.subject.id.clone()),
560        "Agent" => agentguard_core::Principal::agent(req.subject.id.clone()),
561        other => {
562            return Err(format!(
563                "unsupported subject type {:?}: expected User or Agent",
564                other
565            ));
566        }
567    };
568    // AuthZEN action.id is the full action UID like "ToolCall::send_email".
569    // Strip the leading "ToolCall::" to fit agentguard's AgentAction shape.
570    let action_id = req
571        .action
572        .id
573        .strip_prefix("ToolCall::")
574        .unwrap_or(&req.action.id)
575        .to_string();
576    let action = if let Some((tool, op)) = action_id.split_once("::") {
577        agentguard_core::AgentAction::tool_op(tool, op)
578    } else {
579        agentguard_core::AgentAction::tool(action_id)
580    };
581    let resource = agentguard_core::Resource::new(req.resource.entity_type, req.resource.id);
582    let mut context = agentguard_core::AgentContext::new();
583    if let serde_json::Value::Object(map) = &req.context {
584        for (k, v) in map {
585            if k == "session" {
586                if let serde_json::Value::Object(session_map) = v {
587                    for (sk, sv) in session_map {
588                        context = context.with_session(sk, sv.clone());
589                    }
590                }
591            } else {
592                context = context.with_arg(k, v.clone());
593            }
594        }
595    }
596    Ok(AgentRequest::new(principal, action, resource, context))
597}
598
599#[derive(Debug)]
600pub enum EvaluationMappingError {
601    IdentityMismatch,
602    InvalidRequest(String),
603}
604
605/// Map an HTTP or gRPC request while constraining it to the identity carried
606/// by a verified API key. The bound tenant is stored in request/audit metadata,
607/// not trusted from caller-controlled Cedar context. It is not a Cedar policy
608/// attribute; policy isolation must use trusted entities/context explicitly.
609pub fn evaluation_request_for_caller(
610    mut req: EvaluationRequest,
611    caller: Option<&crate::auth_layer::AuthenticatedIdentity>,
612) -> Result<AgentRequest, EvaluationMappingError> {
613    let tenant_id = if let Some(caller) = caller {
614        let identity = &caller.identity;
615        if !caller.can_act_as
616            && (req.subject.entity_type != identity.subject_type
617                || req.subject.id != identity.subject_id)
618        {
619            return Err(EvaluationMappingError::IdentityMismatch);
620        }
621        let supplied_tenant = match req.context.get("tenant_id") {
622            None => None,
623            Some(value) => Some(
624                value
625                    .as_str()
626                    .ok_or(EvaluationMappingError::IdentityMismatch)?,
627            ),
628        };
629        if supplied_tenant.is_some_and(|tenant| Some(tenant) != identity.tenant_id.as_deref()) {
630            return Err(EvaluationMappingError::IdentityMismatch);
631        }
632        if let serde_json::Value::Object(context) = &mut req.context {
633            context.remove("tenant_id");
634        }
635        identity.tenant_id.clone()
636    } else {
637        None
638    };
639    let mut mapped =
640        evaluation_request_to_agent(req).map_err(EvaluationMappingError::InvalidRequest)?;
641    if let Some(tenant_id) = tenant_id {
642        mapped = mapped.with_tenant_id(tenant_id);
643    }
644    Ok(mapped)
645}
646
647/// Build a `cedar_policy::Entities` from the request's `entities` array.
648/// Per-request entities are typical for AuthZEN (each PEP sends the
649/// entities relevant to its call); a future enhancement can layer
650/// shared/static entities on top.
651pub fn build_request_entities(items: &[serde_json::Value]) -> Result<Entities, String> {
652    build_entities(items.to_vec()).map_err(|e| format!("entities: {}", e))
653}
654
655#[tracing::instrument(
656    skip_all,
657    fields(subject = %req.subject.id, action = %req.action.id)
658)]
659async fn evaluation(
660    State(state): State<AppState>,
661    caller: Option<Extension<crate::auth_layer::AuthenticatedIdentity>>,
662    Json(req): Json<EvaluationRequest>,
663) -> Response {
664    let per_request_entities = req.entities.clone();
665    let agent_req = match evaluation_request_for_caller(req, caller.as_ref().map(|c| &c.0)) {
666        Ok(r) => r,
667        Err(EvaluationMappingError::IdentityMismatch) => {
668            return (
669                StatusCode::FORBIDDEN,
670                "request identity does not match credential",
671            )
672                .into_response()
673        }
674        Err(EvaluationMappingError::InvalidRequest(e)) => {
675            return (StatusCode::BAD_REQUEST, e).into_response()
676        }
677    };
678    let action_label = format!("{}", agent_req.action);
679    let outcome = authorize_and_persist_decision(
680        state.pdp_work_slots.clone(),
681        state.authorizer.snapshot(),
682        agent_req,
683        per_request_entities,
684        state.audit_appender.clone(),
685        authenticated_actor(caller.as_ref().map(|c| &c.0)),
686    )
687    .await;
688    match outcome {
689        Ok((decision, elapsed)) => {
690            let effect_label = match decision.effect {
691                Effect::Allow => "allow",
692                Effect::Deny => "deny",
693            };
694            // tenant_id is optional in our model; empty string keeps
695            // the cardinality low but still distinguishable from a
696            // multi-tenant deployment that does set it.
697            let tenant_label = "";
698            let policy_id = decision
699                .policies
700                .first()
701                .cloned()
702                .unwrap_or_else(|| "none".into());
703            state.metrics().record_decision(
704                effect_label,
705                &policy_id,
706                &action_label,
707                tenant_label,
708                elapsed,
709            );
710            if decision.from_cache {
711                state.metrics().record_cache_hit();
712            } else {
713                state.metrics().record_cache_miss();
714            }
715            let resp = EvaluationResponse {
716                decision: matches!(decision.effect, Effect::Allow),
717                context: None,
718                reason: decision.reasons.first().cloned(),
719            };
720            (StatusCode::OK, Json(resp)).into_response()
721        }
722        Err(error) => {
723            let (status, message) = report_pdp_work_failure(&state, error);
724            (status, message).into_response()
725        }
726    }
727}
728
729async fn evaluations(
730    State(state): State<AppState>,
731    caller: Option<Extension<crate::auth_layer::AuthenticatedIdentity>>,
732    Json(req): Json<BatchEvaluationRequest>,
733) -> Response {
734    // ponytail: cap the batch size here too. Body limit caps total
735    // bytes but a tight loop of tiny items would still slip through.
736    if req.evaluations.len() > MAX_BATCH_EVALUATIONS {
737        return (
738            StatusCode::PAYLOAD_TOO_LARGE,
739            format!(
740                "batch too large: {} > {} max evaluations",
741                req.evaluations.len(),
742                MAX_BATCH_EVALUATIONS
743            ),
744        )
745            .into_response();
746    }
747    let semantics = req.evaluation_semantics.unwrap_or_default();
748    let mut responses = Vec::with_capacity(req.evaluations.len());
749    // A batch is one logical evaluation operation. Pin a single immutable
750    // policy generation so a concurrent reload cannot mix decisions from
751    // different snapshots inside the same response.
752    let authorizer = state.authorizer.snapshot();
753
754    for er in req.evaluations {
755        let per_request_entities = er.entities.clone();
756        let agent_req = match evaluation_request_for_caller(er, caller.as_ref().map(|c| &c.0)) {
757            Ok(r) => r,
758            Err(EvaluationMappingError::IdentityMismatch) => {
759                return (
760                    StatusCode::FORBIDDEN,
761                    "request identity does not match credential",
762                )
763                    .into_response()
764            }
765            Err(EvaluationMappingError::InvalidRequest(e)) => {
766                return (StatusCode::BAD_REQUEST, e).into_response()
767            }
768        };
769        match authorize_and_persist_decision(
770            state.pdp_work_slots.clone(),
771            authorizer.clone(),
772            agent_req,
773            per_request_entities,
774            state.audit_appender.clone(),
775            authenticated_actor(caller.as_ref().map(|c| &c.0)),
776        )
777        .await
778        {
779            Ok((decision, _elapsed)) => {
780                let allow = matches!(decision.effect, Effect::Allow);
781                responses.push(EvaluationResponse {
782                    decision: allow,
783                    context: None,
784                    reason: decision.reasons.first().cloned(),
785                });
786                match semantics {
787                    EvaluationSemantics::DenyOnFirstDeny if !allow => break,
788                    EvaluationSemantics::PermitOnFirstPermit if allow => break,
789                    _ => {}
790                }
791            }
792            Err(error) => {
793                let (status, message) = report_pdp_work_failure(&state, error);
794                return (status, message).into_response();
795            }
796        }
797    }
798
799    (
800        StatusCode::OK,
801        Json(BatchEvaluationResponse {
802            evaluations: responses,
803        }),
804    )
805        .into_response()
806}
807
808/// Build an [`AppState`] from a policy store on disk + an optional
809/// audit log. If `chain_secret` is `Some`, the audit log is opened
810/// in chained (HMAC) mode; otherwise plain JSONL.
811///
812/// # Errors
813/// Returns a `String` error if the store can't be opened or the cedar
814/// engine can't be initialized. The error string is suitable for an HTTP
815/// 500 response body.
816pub async fn build_state(
817    store_root: std::path::PathBuf,
818    audit_log: Option<std::path::PathBuf>,
819    chain_secret: Option<Vec<u8>>,
820    auth: crate::auth_layer::AuthLayer,
821) -> Result<AppState, String> {
822    build_state_with_options(
823        store_root,
824        audit_log,
825        chain_secret,
826        auth,
827        AppStateOptions {
828            cache: Some(CacheConfig::default()),
829            audit_rotation: None,
830        },
831    )
832    .await
833}
834
835/// Like [`build_state`] but takes an explicit `CacheConfig`. Passing `None`
836/// disables caching. This function never reads process environment.
837pub async fn build_state_with_cache(
838    store_root: std::path::PathBuf,
839    audit_log: Option<std::path::PathBuf>,
840    chain_secret: Option<Vec<u8>>,
841    auth: crate::auth_layer::AuthLayer,
842    cache: Option<CacheConfig>,
843) -> Result<AppState, String> {
844    build_state_with_options(
845        store_root,
846        audit_log,
847        chain_secret,
848        auth,
849        AppStateOptions {
850            cache,
851            audit_rotation: None,
852        },
853    )
854    .await
855}
856
857/// Construct app state with explicit cache and audit-rotation options.
858pub async fn build_state_with_options(
859    store_root: std::path::PathBuf,
860    audit_log: Option<std::path::PathBuf>,
861    chain_secret: Option<Vec<u8>>,
862    auth: crate::auth_layer::AuthLayer,
863    options: AppStateOptions,
864) -> Result<AppState, String> {
865    let authorizer = AuthorizerHandle::new(store_root, options.cache)?;
866    let audit = match audit_log {
867        Some(path) => {
868            let log = match (chain_secret, options.audit_rotation) {
869                (Some(secret), Some(rotation)) => {
870                    DecisionLog::open_with_rotation(&path, Some(&secret), rotation)
871                        .map_err(|e| format!("open rotating chained audit log: {}", e))?
872                }
873                (Some(secret), None) => DecisionLog::open_with_chain(&path, &secret)
874                    .map_err(|e| format!("open chained audit log: {}", e))?,
875                (None, Some(rotation)) => DecisionLog::open_with_rotation(&path, None, rotation)
876                    .map_err(|e| format!("open rotating audit log: {}", e))?,
877                (None, None) => {
878                    DecisionLog::open(&path).map_err(|e| format!("open audit log: {}", e))?
879                }
880            };
881            Some(log)
882        }
883        None => None,
884    };
885    let audit = audit.map(Arc::new);
886    let audit_appender: Option<AuditWriter> = audit
887        .as_ref()
888        .map(Arc::clone)
889        .map(|log| AuditWriter::Sync(log as Arc<dyn AuditAppender>));
890    Ok(AppState {
891        authorizer,
892        audit: Arc::new(audit),
893        audit_appender: Arc::new(audit_appender),
894        auth,
895        metrics: Arc::new(Metrics::new()),
896        pdp_work_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_PDP_WORK_ITEMS)),
897    })
898}
899
900#[cfg(test)]
901mod summarize_tests {
902    use super::{
903        router, summarize_authorize_error, AppStateOptions, AuditWriter,
904        MAX_CONCURRENT_PDP_WORK_ITEMS,
905    };
906    use crate::audit::AuditAppender;
907    use agentguard_core::{Decision, Error, PolicyStore};
908    use axum::body::Body;
909    use axum::http::{Request, StatusCode};
910    use std::sync::Arc;
911    use tempfile::tempdir;
912    use tokio::sync::Semaphore;
913    use tower::ServiceExt;
914
915    struct FailingAuditAppender;
916
917    impl AuditAppender for FailingAuditAppender {
918        fn append_decision(&self, _decision: &Decision) -> agentguard_core::Result<()> {
919            Err(Error::Io("disk full".into()))
920        }
921
922        fn is_healthy(&self) -> bool {
923            false
924        }
925
926        fn is_chained(&self) -> bool {
927            true
928        }
929    }
930
931    #[tokio::test]
932    async fn audit_write_failure_fails_closed_and_marks_readiness_unhealthy() {
933        let dir = tempdir().unwrap();
934        let store = PolicyStore::open(dir.path()).unwrap();
935        store
936            .write_policy("allow", "permit(principal, action, resource);")
937            .unwrap();
938        let mut state = super::build_state(
939            dir.path().to_path_buf(),
940            Some(dir.path().join("audit.jsonl")),
941            Some(b"test-key".to_vec()),
942            crate::auth_layer::AuthLayer::Disabled,
943        )
944        .await
945        .unwrap();
946        state.audit_appender = Arc::new(Some(AuditWriter::Sync(Arc::new(FailingAuditAppender))));
947
948        let app = router(state);
949        let response = app
950            .clone()
951            .oneshot(
952                Request::post("/access/v1/evaluation")
953                    .header("content-type", "application/json")
954                    .body(Body::from(
955                        r#"{"subject":{"type":"User","id":"alice"},"action":{"type":"Action","id":"read"},"resource":{"type":"Document","id":"doc"},"context":{}}"#,
956                    ))
957                    .unwrap(),
958            )
959            .await
960            .unwrap();
961        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
962
963        let response = app
964            .oneshot(Request::get("/readyz").body(Body::empty()).unwrap())
965            .await
966            .unwrap();
967        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
968    }
969
970    #[tokio::test]
971    async fn embedded_state_accepts_explicit_runtime_options() {
972        let dir = tempdir().unwrap();
973        let audit_path = dir.path().join("audit.jsonl");
974        let state = super::build_state_with_options(
975            dir.path().to_path_buf(),
976            Some(audit_path.clone()),
977            None,
978            crate::auth_layer::AuthLayer::Disabled,
979            AppStateOptions {
980                cache: None,
981                audit_rotation: Some(agentguard_core::decision::RotationConfig { max_bytes: 1024 }),
982            },
983        )
984        .await
985        .unwrap();
986
987        assert_eq!(state.audit().unwrap().path(), audit_path);
988
989        let clone = state.clone();
990        let permits = state
991            .pdp_work_slots
992            .clone()
993            .try_acquire_many_owned(MAX_CONCURRENT_PDP_WORK_ITEMS as u32)
994            .unwrap();
995        assert!(clone.pdp_work_slots.clone().try_acquire_owned().is_err());
996
997        let independent = super::build_state(
998            dir.path().to_path_buf(),
999            None,
1000            None,
1001            crate::auth_layer::AuthLayer::Disabled,
1002        )
1003        .await
1004        .unwrap();
1005        assert!(independent
1006            .pdp_work_slots
1007            .clone()
1008            .try_acquire_owned()
1009            .is_ok());
1010        drop(permits);
1011    }
1012
1013    #[tokio::test]
1014    async fn embedded_state_rejects_invalid_cache_options_without_panicking() {
1015        let dir = tempdir().unwrap();
1016        let result = super::build_state_with_options(
1017            dir.path().to_path_buf(),
1018            None,
1019            None,
1020            crate::auth_layer::AuthLayer::Disabled,
1021            AppStateOptions {
1022                cache: Some(agentguard_core::decision::cache::CacheConfig {
1023                    capacity: 0,
1024                    ..agentguard_core::decision::cache::CacheConfig::default()
1025                }),
1026                audit_rotation: None,
1027            },
1028        )
1029        .await;
1030
1031        let error = result
1032            .err()
1033            .expect("invalid cache options should fail state construction");
1034        assert!(error.contains("decision cache configuration"));
1035        assert!(error.contains("capacity must be greater than zero"));
1036    }
1037
1038    #[test]
1039    fn invalid_context_summary_does_not_leak_message() {
1040        // The Cedar error text echoes policy / schema fragments.
1041        // The sanitizer must produce a code + summary that does
1042        // NOT include any of that text.
1043        let err = Error::InvalidContext(
1044            "policy 'permit(principal, action, resource) when { secret == \"hunter2\" }': \
1045             unresolved attribute: secret"
1046                .to_string(),
1047        );
1048        let (code, summary) = summarize_authorize_error(&err);
1049        assert_eq!(code, "invalid_context");
1050        assert!(
1051            !summary.contains("hunter2"),
1052            "summary leaked policy text: {summary}"
1053        );
1054        assert!(
1055            !summary.contains("permit"),
1056            "summary leaked policy text: {summary}"
1057        );
1058    }
1059
1060    #[test]
1061    fn reload_replaces_snapshot_and_retains_last_good_on_parse_error() {
1062        let dir = tempdir().unwrap();
1063        let store = PolicyStore::open(dir.path()).unwrap();
1064        store
1065            .write_policy("initial", "permit(principal, action, resource);")
1066            .unwrap();
1067
1068        let handle = super::AuthorizerHandle::new(dir.path().to_path_buf(), None).unwrap();
1069        assert_eq!(handle.policy_count(), 1);
1070        let initial_snapshot = handle.snapshot();
1071
1072        store
1073            .write_policy("second", "forbid(principal, action, resource);")
1074            .unwrap();
1075        handle.reload().unwrap();
1076        assert_eq!(handle.policy_count(), 2);
1077        assert_eq!(initial_snapshot.policy_count(), 1);
1078
1079        store
1080            .write_policy("broken", "permit (this is not Cedar")
1081            .unwrap();
1082        assert!(handle.reload().is_err());
1083        assert_eq!(handle.policy_count(), 2);
1084
1085        std::fs::remove_file(store.policies_dir().join("broken.cedar")).unwrap();
1086        std::fs::write(
1087            store.policies_dir().join("oversized.cedar"),
1088            vec![b'x'; 1_048_577],
1089        )
1090        .unwrap();
1091        let error = handle.reload().unwrap_err();
1092        assert!(error.contains("policy size limit"));
1093        assert_eq!(handle.policy_count(), 2);
1094    }
1095
1096    #[tokio::test]
1097    async fn pdp_work_capacity_saturation_fails_fast() {
1098        let result =
1099            super::run_bounded_pdp_work(std::sync::Arc::new(Semaphore::new(0)), || Ok(())).await;
1100        assert!(matches!(result, Err(super::PdpWorkError::Saturated)));
1101    }
1102
1103    #[tokio::test]
1104    async fn slow_pdp_work_does_not_stall_async_workers() {
1105        use std::sync::atomic::{AtomicUsize, Ordering};
1106        use std::time::Duration;
1107
1108        let ticks = std::sync::Arc::new(AtomicUsize::new(0));
1109        let tick_count = ticks.clone();
1110        let ticker = tokio::spawn(async move {
1111            let mut interval = tokio::time::interval(Duration::from_millis(10));
1112            loop {
1113                interval.tick().await;
1114                tick_count.fetch_add(1, Ordering::Relaxed);
1115            }
1116        });
1117        let result = super::run_bounded_pdp_work(std::sync::Arc::new(Semaphore::new(1)), || {
1118            std::thread::sleep(Duration::from_millis(80));
1119            Ok(())
1120        })
1121        .await;
1122        ticker.abort();
1123
1124        assert!(result.is_ok());
1125        assert!(
1126            ticks.load(Ordering::Relaxed) >= 3,
1127            "async work should continue while PDP work runs in the blocking pool"
1128        );
1129    }
1130
1131    #[tokio::test]
1132    async fn standalone_state_enables_default_decision_cache() {
1133        let dir = tempdir().unwrap();
1134        let store = PolicyStore::open(dir.path()).unwrap();
1135        store
1136            .write_policy("initial", "permit(principal, action, resource);")
1137            .unwrap();
1138
1139        let state = super::build_state(
1140            dir.path().to_path_buf(),
1141            None,
1142            None,
1143            crate::auth_layer::AuthLayer::Disabled,
1144        )
1145        .await
1146        .unwrap();
1147
1148        assert!(state.authorizer.cache.is_some());
1149    }
1150
1151    #[test]
1152    fn policy_parse_summary_does_not_leak_filename() {
1153        let err = Error::PolicyParse {
1154            message: "unexpected token at column 7".into(),
1155            file: "/etc/agentguard/policies/30_strands.cedar".into(),
1156        };
1157        let (code, summary) = summarize_authorize_error(&err);
1158        assert_eq!(code, "policy_parse");
1159        assert!(
1160            !summary.contains("30_strands.cedar"),
1161            "summary leaked file path: {summary}"
1162        );
1163        assert!(
1164            !summary.contains("column 7"),
1165            "summary leaked parse position: {summary}"
1166        );
1167    }
1168
1169    #[test]
1170    fn revoked_delegation_summary_is_stable_and_does_not_leak_token_id() {
1171        let err = Error::TokenRevoked("sensitive-token-id".into());
1172        let (code, summary) = summarize_authorize_error(&err);
1173        assert_eq!(code, "token_revoked");
1174        assert_eq!(summary, "delegation token revoked");
1175        assert!(!summary.contains("sensitive-token-id"));
1176    }
1177}