1use 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
26const 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
167pub const MAX_BATCH_EVALUATIONS: usize = 100;
171
172#[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 #[serde(default)]
193 pub entities: Vec<serde_json::Value>,
194}
195
196#[derive(Debug, Deserialize, Serialize)]
198pub struct EntityRef {
199 #[serde(rename = "type")]
200 pub entity_type: String,
201 pub id: String,
202}
203
204#[derive(Debug, Serialize, Deserialize)]
206pub struct EvaluationResponse {
207 pub decision: bool,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub context: Option<serde_json::Value>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub reason: Option<String>,
215}
216
217#[derive(Debug, Deserialize, Serialize, Default)]
219#[serde(rename_all = "snake_case")]
220pub enum EvaluationSemantics {
221 #[default]
223 ExecuteAll,
224 DenyOnFirstDeny,
226 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#[derive(Clone)]
253pub struct AppState {
254 authorizer: AuthorizerHandle,
255 audit: Arc<Option<Arc<DecisionLog>>>,
259 audit_appender: Arc<Option<AuditWriter>>,
260 pub auth: crate::auth_layer::AuthLayer,
263 pub metrics: Arc<Metrics>,
266 pub(crate) pdp_work_slots: Arc<Semaphore>,
269}
270
271#[derive(Debug, Clone, Default)]
277pub struct AppStateOptions {
278 pub cache: Option<CacheConfig>,
280 pub audit_rotation: Option<RotationConfig>,
282}
283
284#[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 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 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 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 pub fn authorizer(&self) -> &AuthorizerHandle {
381 &self.authorizer
382 }
383
384 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 pub fn metrics(&self) -> &Arc<Metrics> {
397 &self.metrics
398 }
399}
400
401pub 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 .layer(from_fn_with_state(
425 state.clone(),
426 crate::auth_layer::auth_layer_fn,
427 ))
428 .layer(axum::extract::DefaultBodyLimit::max(64 * 1024))
431 .layer(from_fn(trace_context_layer))
437 .with_state(state)
438}
439
440async 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
453async 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 let child = trace.child();
463 let span_id = child.span_id;
464
465 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 if state.authorizer.policy_count() == 0 {
487 return readyz_unavailable("policies not loaded");
488 }
489 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
521fn 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 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 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
605pub 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
647pub 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 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 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 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
808pub 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
835pub 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
857pub 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 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}