agentguard_server/
server.rs

1//! Server entry point: `agentguard-server`.
2//!
3//! Callers that need to embed the server in their own binary should use
4//! [`run`] directly. The binary in `bin/agentguard-server.rs` parses
5//! CLI args and constructs a `ServerConfig` from them.
6//!
7//! The previous `make_run` and `config_from_env` helpers were dead code —
8//! they were not called by any caller in the workspace. Removed in v0.2.0
9//! as a deliberate API cleanup. External users who relied on them should
10//! inline the equivalent at the call site:
11//!
12//! ```ignore
13//! use agentguard_server::{run, ServerConfig};
14//! use agentguard_server::listener::Listener;
15//! let cfg = ServerConfig {
16//!     listener: Listener::Tcp("127.0.0.1:8443".parse().unwrap()),
17//!     store_root: ".agentguard".into(),
18//!     audit_log: Some(".audit/decisions.jsonl".into()),
19//!     chain_secret: None,
20//! };
21//! agentguard_server::run(cfg).await?;
22//! ```
23
24use crate::auth_layer::AuthLayer;
25use crate::authzen::{build_state, build_state_with_options, router, AppStateOptions};
26use crate::listener::{Listener, ServerConfig};
27use agentguard_core::decision::{cache::DecisionCache, RotationConfig};
28use agentguard_core::decode_chain_secret;
29use agentguard_policy::watcher::{watch as policy_watch, PolicyWatcher};
30use anyhow::{anyhow, Result};
31use axum::serve::serve;
32use std::path::Path;
33use std::sync::Arc;
34use std::time::Duration;
35use tokio::net::TcpListener;
36use tokio::signal;
37
38/// Validate that both TLS files exist, are readable, and are
39/// non-empty. Used at the top of [`run`] so a misconfigured cert /
40/// key path fails before the policy store loads or the HTTP
41/// listener binds.
42pub(crate) fn validate_tls_paths(cert: &Path, key: &Path) -> Result<()> {
43    for (label, path) in [("cert", cert), ("key", key)] {
44        let meta = std::fs::metadata(path)
45            .map_err(|e| anyhow!("tls {label} {:?} not readable: {e}", path.display()))?;
46        if !meta.is_file() {
47            return Err(anyhow!(
48                "tls {label} {:?} is not a regular file",
49                path.display()
50            ));
51        }
52        if meta.len() == 0 {
53            return Err(anyhow!("tls {label} {:?} is empty", path.display()));
54        }
55    }
56    Ok(())
57}
58
59fn validate_grpc_listener(addr: std::net::SocketAddr) -> Result<()> {
60    if !addr.ip().is_loopback() {
61        return Err(anyhow!(
62            "gRPC listener must be loopback-bound until TLS is supported"
63        ));
64    }
65    Ok(())
66}
67
68fn parse_audit_rotation(value: Option<&str>) -> Result<Option<RotationConfig>> {
69    value
70        .map(RotationConfig::parse)
71        .transpose()
72        .map_err(|error| anyhow!("AGENTGUARD_AUDIT_MAX_BYTES {error}"))
73}
74
75fn validate_listener_security(
76    listener: &Listener,
77    grpc_listener: Option<std::net::SocketAddr>,
78    auth: &crate::listener::AuthConfig,
79    allow_loopback_bypass: bool,
80) -> Result<()> {
81    if let Some(addr) = grpc_listener {
82        validate_grpc_listener(addr)?;
83    }
84    if matches!(auth, crate::listener::AuthConfig::Disabled)
85        && listener.is_public()
86        && !allow_loopback_bypass
87    {
88        return Err(anyhow!(
89            "auth is disabled but the listener is not loopback-bound; \
90             set AGENTGUARD_AUTH=apikey:<path> or AGENTGUARD_ALLOW_LOOPBACK_BYPASS=1"
91        ));
92    }
93    Ok(())
94}
95
96/// Run the server. Returns when the listener stops (e.g. on SIGTERM/SIGINT).
97/// In-flight requests are allowed to complete before the process exits.
98///
99/// # Errors
100/// Returns an error if the listener can't be bound, the TLS material is
101/// invalid, or the policy store can't be loaded.
102pub async fn run(cfg: ServerConfig) -> Result<()> {
103    let cache_config = DecisionCache::try_config_from_env()
104        .map_err(|error| anyhow!("invalid decision cache configuration: {error}"))?;
105    let audit_rotation = match std::env::var("AGENTGUARD_AUDIT_MAX_BYTES") {
106        Ok(value) => parse_audit_rotation(Some(&value))?,
107        Err(std::env::VarError::NotPresent) => parse_audit_rotation(None)?,
108        Err(std::env::VarError::NotUnicode(_)) => {
109            return Err(anyhow!("AGENTGUARD_AUDIT_MAX_BYTES must be valid Unicode"));
110        }
111    };
112    // Validate TLS paths early so a bad tls://addr?cert=PATH&key=PATH
113    // is reported at startup, not after the policy store loads and
114    // the HTTP listener binds.
115    if let crate::listener::Listener::Tls { cert, key, .. } = &cfg.listener {
116        validate_tls_paths(cert, key)?;
117    }
118    let allow_loopback_bypass = std::env::var("AGENTGUARD_ALLOW_LOOPBACK_BYPASS")
119        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
120        .unwrap_or(false);
121    validate_listener_security(
122        &cfg.listener,
123        cfg.grpc_listener,
124        &cfg.auth,
125        allow_loopback_bypass,
126    )?;
127    if matches!(cfg.auth, crate::listener::AuthConfig::Disabled)
128        && cfg.listener.is_public()
129        && allow_loopback_bypass
130    {
131        tracing::warn!(
132            "AGENTGUARD_ALLOW_LOOPBACK_BYPASS=1: serving unauthenticated decisions on a public listener; \
133             this should only happen behind a trusted reverse proxy"
134        );
135    }
136    let auth = AuthLayer::from_config(&cfg.auth, allow_loopback_bypass)
137        .map_err(|e| anyhow!("auth layer: {}", e))?;
138    let key_watcher_target = auth.reloadable_key_store();
139    let chain_secret = match &cfg.chain_secret {
140        Some(path) => {
141            let bytes =
142                std::fs::read(path).map_err(|e| anyhow!("read chain secret {:?}: {}", path, e))?;
143            let bytes = decode_chain_secret(&bytes)
144                .ok_or_else(|| anyhow!("chain secret file {:?} is empty", path))?;
145            Some(bytes)
146        }
147        None => {
148            if cfg.audit_log.is_some() {
149                tracing::warn!(
150                    "AGENTGUARD_CHAIN_SECRET is not set; audit log will be plain JSONL (no tamper evidence)"
151                );
152            }
153            None
154        }
155    };
156    let state = Arc::new(
157        build_state_with_options(
158            cfg.store_root.clone(),
159            cfg.audit_log.clone(),
160            chain_secret,
161            auth,
162            AppStateOptions {
163                cache: Some(cache_config),
164                audit_rotation,
165            },
166        )
167        .await
168        .map_err(|e| anyhow!("build state: {}", e))?,
169    );
170    let watcher_handle =
171        try_spawn_policy_watcher(cfg.store_root.clone(), state.clone() as Arc<dyn ReloadSink>)
172            .map_err(|error| anyhow!("initialize policy watcher: {error}"))?;
173    let key_watcher_handle =
174        key_watcher_target.map(|(path, store)| spawn_api_key_watcher(path, store));
175    let app = router((*state).clone());
176    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
177
178    // Optional gRPC sidecar: when AGENTGUARD_GRPC_LISTEN is set,
179    // spawn a tonic server on the given address alongside the HTTP
180    // server. Same AppState, same authorizer — only the transport
181    // differs.
182    let grpc_handle = if let Some(addr) = cfg.grpc_listener {
183        let svc = crate::grpc::service(state.clone());
184        tracing::info!("agentguard gRPC listening on tcp://{}", addr);
185        Some(tokio::spawn(async move {
186            let mut shutdown_rx = shutdown_rx;
187            let res = tonic::transport::Server::builder()
188                .add_service(svc)
189                .serve_with_shutdown(addr, async move {
190                    if !*shutdown_rx.borrow() {
191                        let _ = shutdown_rx.changed().await;
192                    }
193                })
194                .await;
195            if let Err(e) = res {
196                tracing::error!(error = %e, "gRPC server exited with error");
197            }
198        }))
199    } else {
200        None
201    };
202
203    match cfg.listener.clone() {
204        Listener::Tcp(addr) => {
205            let listener = TcpListener::bind(addr).await?;
206            tracing::info!("agentguard listening on tcp://{}", addr);
207            serve(listener, app.into_make_service())
208                // The orchestrator owns the hard termination deadline
209                // (terminationGracePeriodSeconds in the Kubernetes base).
210                // Do not timeout the signal future itself: doing so asks
211                // Axum to shut down after 30 seconds even when no signal was
212                // received.
213                .with_graceful_shutdown(async move {
214                    shutdown_signal_with_sighup(state.clone()).await;
215                    let _ = shutdown_tx.send(true);
216                })
217                .await?;
218        }
219        Listener::Tls { addr, cert, key } => {
220            use axum_server::tls_rustls::RustlsConfig;
221            let cfg = RustlsConfig::from_pem_file(cert, key).await?;
222            tracing::info!("agentguard listening on tls://{}", addr);
223            // axum_server::Handle exposes shutdown; use it to coordinate
224            // with our signal handler. The orchestrator owns the hard
225            // termination deadline.
226            let handle = axum_server::Handle::new();
227            let signal_handle = handle.clone();
228            let state_for_signal = state.clone();
229            let grpc_shutdown_tx = shutdown_tx.clone();
230            tokio::spawn(async move {
231                shutdown_signal_with_sighup(state_for_signal).await;
232                let _ = grpc_shutdown_tx.send(true);
233                signal_handle.shutdown();
234            });
235            axum_server::bind_rustls(addr, cfg)
236                .handle(handle)
237                .serve(app.into_make_service())
238                .await?;
239        }
240    }
241
242    watcher_handle.abort();
243    if let Some(handle) = key_watcher_handle {
244        handle.abort();
245    }
246    if let Some(h) = grpc_handle {
247        if let Err(error) = h.await {
248            tracing::error!(%error, "gRPC shutdown task failed");
249        }
250    }
251    tracing::info!("agentguard stopped cleanly");
252    Ok(())
253}
254
255/// Build a ready-to-serve `Router` from the config. Exposed for tests
256/// and embedders that want to run the AuthZEN app inside their own
257/// hyper server.
258///
259/// `allow_loopback_bypass`: when `true`, a config with auth disabled
260/// may be served on a non-loopback listener (intended only for
261/// tests and embedders behind a trusted reverse proxy). Production
262/// callers should pass `false` so the security guard fires.
263pub async fn build_router(
264    cfg: ServerConfig,
265    allow_loopback_bypass: bool,
266) -> Result<(axum::Router, Arc<crate::authzen::AppState>)> {
267    let auth = AuthLayer::from_config(&cfg.auth, allow_loopback_bypass)
268        .map_err(|e| anyhow!("auth layer: {}", e))?;
269    let chain_secret = match &cfg.chain_secret {
270        Some(path) => {
271            let bytes =
272                std::fs::read(path).map_err(|e| anyhow!("read chain secret {:?}: {}", path, e))?;
273            decode_chain_secret(&bytes)
274        }
275        None => None,
276    };
277    let state = build_state(
278        cfg.store_root.clone(),
279        cfg.audit_log.clone(),
280        chain_secret,
281        auth,
282    )
283    .await
284    .map_err(|e| anyhow!("build state: {}", e))?;
285    let app = router(state.clone());
286    Ok((app, Arc::new(state)))
287}
288
289/// Minimal sink the watcher needs from app state. Implemented by
290/// `AppState` so tests can pass a fake.
291pub trait ReloadSink: Send + Sync + 'static {
292    /// Replace the policy snapshot and bump `policy_reload_total`.
293    fn reload(&self);
294}
295
296impl ReloadSink for crate::authzen::AppState {
297    fn reload(&self) {
298        match self.authorizer().reload() {
299            Ok(()) => {
300                self.metrics().record_policy_reload();
301                tracing::info!("policy snapshot reloaded");
302            }
303            Err(error) => {
304                tracing::error!(%error, "policy reload failed; retaining last known-good snapshot");
305            }
306        }
307    }
308}
309
310/// Start the policy hot-reload watcher, returning an error if its filesystem
311/// watch cannot be initialized. The task polls every 500 ms and reloads on
312/// relevant policy/schema changes.
313pub fn try_spawn_policy_watcher(
314    store_root: std::path::PathBuf,
315    sink: Arc<dyn ReloadSink>,
316) -> std::io::Result<tokio::task::JoinHandle<()>> {
317    let watcher = policy_watch(&store_root, Duration::from_millis(250))?;
318    Ok(spawn_policy_watcher_task(watcher, sink))
319}
320
321/// Best-effort compatibility wrapper for callers that want watcher setup
322/// failures logged asynchronously instead of returned.
323pub fn spawn_policy_watcher(
324    store_root: std::path::PathBuf,
325    sink: Arc<dyn ReloadSink>,
326) -> tokio::task::JoinHandle<()> {
327    match policy_watch(&store_root, Duration::from_millis(250)) {
328        Ok(watcher) => spawn_policy_watcher_task(watcher, sink),
329        Err(error) => tokio::spawn(async move {
330            tracing::error!(
331                store_root = %store_root.display(),
332                error = %error,
333                "policy watcher init failed; hot reload disabled"
334            );
335        }),
336    }
337}
338
339fn spawn_policy_watcher_task(
340    mut watcher: PolicyWatcher,
341    sink: Arc<dyn ReloadSink>,
342) -> tokio::task::JoinHandle<()> {
343    tokio::spawn(async move {
344        let mut interval = tokio::time::interval(Duration::from_millis(500));
345        loop {
346            interval.tick().await;
347            let events = watcher.events();
348            for error in watcher.take_errors() {
349                tracing::error!(%error, "policy filesystem watcher failed");
350            }
351            if events.is_empty() {
352                continue;
353            }
354            sink.reload();
355            tracing::info!(events = events.len(), "policy reload triggered by watcher");
356        }
357    })
358}
359
360/// Poll the API-key file contents and reload complete snapshots after
361/// projected-secret updates or atomic CLI writes. Kubernetes updates Secret
362/// volumes by swapping symlinks, which is not consistently reported by
363/// directory watchers across filesystems. Invalid snapshots retain the
364/// current key set; a later distinct file snapshot is retried.
365pub fn spawn_api_key_watcher(
366    path: std::path::PathBuf,
367    store: Arc<agentguard_auth::ApiKeyStore>,
368) -> tokio::task::JoinHandle<()> {
369    // Capture the baseline before spawning so an update immediately after
370    // this function returns cannot be mistaken for the initial snapshot.
371    let mut last_attempted_snapshot = std::fs::read(&path).ok();
372    if let Err(error) = store.reload_from_file(&path) {
373        tracing::error!(
374            key_store = %path.display(),
375            %error,
376            "initial API-key store refresh failed; retaining last known-good key set"
377        );
378    }
379    tokio::spawn(async move {
380        let mut interval = tokio::time::interval(Duration::from_millis(250));
381        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
382        let mut last_read_error_log = None;
383        loop {
384            interval.tick().await;
385            match std::fs::read(&path) {
386                Ok(snapshot) => {
387                    last_read_error_log = None;
388                    if last_attempted_snapshot.as_ref() != Some(&snapshot) {
389                        match store.reload_from_file(&path) {
390                            Ok(()) => tracing::info!(
391                                key_store = %path.display(),
392                                "API-key store reloaded"
393                            ),
394                            Err(error) => tracing::error!(
395                                key_store = %path.display(),
396                                %error,
397                                "API-key store reload failed; retaining last known-good key set"
398                            ),
399                        }
400                        // Suppress repeated errors for an unchanged invalid
401                        // projection; a new byte snapshot automatically retries.
402                        last_attempted_snapshot = Some(snapshot);
403                    }
404                }
405                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
406                    if last_attempted_snapshot.take().is_some() {
407                        match store.reload_from_file(&path) {
408                            Ok(()) => tracing::info!(
409                                key_store = %path.display(),
410                                "API-key store removed; active keys cleared"
411                            ),
412                            Err(error) => tracing::error!(
413                                key_store = %path.display(),
414                                %error,
415                                "API-key store removal reload failed; retaining last known-good key set"
416                            ),
417                        }
418                    }
419                }
420                Err(error) => {
421                    let should_log = last_read_error_log
422                        .map(|last: std::time::Instant| last.elapsed() >= Duration::from_secs(30))
423                        .unwrap_or(true);
424                    if should_log {
425                        tracing::error!(
426                            key_store = %path.display(),
427                            %error,
428                            "API-key store read failed; retaining last known-good key set"
429                        );
430                        last_read_error_log = Some(std::time::Instant::now());
431                    }
432                }
433            }
434        }
435    })
436}
437
438/// Block until SIGINT or SIGTERM is received. SIGHUP is handled
439/// inline: each received SIGHUP triggers an immediate policy snapshot
440/// reload so operators can force a
441/// refresh without touching the filesystem. The loop is iterative
442/// (no recursion) so multiple SIGHUPs don't grow the stack.
443pub async fn shutdown_signal_with_sighup(state: Arc<crate::authzen::AppState>) {
444    use tokio::signal::unix as u;
445
446    // Best-effort installation of signal handlers. If install fails
447    // (e.g. inside a sandbox) we park that branch forever so the
448    // shutdown wait stays well-defined.
449    #[cfg(unix)]
450    let (mut terminate, mut sighup) = {
451        let t = u::signal(u::SignalKind::terminate()).ok();
452        let h = u::signal(u::SignalKind::hangup()).ok();
453        (t, h)
454    };
455    #[cfg(not(unix))]
456    let (mut terminate, mut sighup): (Option<Never>, Option<Never>) = (None, None);
457
458    loop {
459        // Pick the first signal that fires.
460        tokio::select! {
461            // ctrl_c returns Result<(), io::Error>; a sandbox that
462            // can't install the handler reports Err — we park forever
463            // so shutdown stays well-defined.
464            res = signal::ctrl_c() => {
465                if res.is_err() {
466                    std::future::pending::<()>().await;
467                } else {
468                    tracing::info!("SIGINT received, draining");
469                    break;
470                }
471            }
472            _ = async {
473                match terminate.as_mut() {
474                    Some(s) => { let _ = s.recv().await; }
475                    None => std::future::pending::<()>().await,
476                }
477            } => {
478                tracing::info!("SIGTERM received, draining");
479                break;
480            }
481            _ = async {
482                match sighup.as_mut() {
483                    Some(s) => { let _ = s.recv().await; }
484                    None => std::future::pending::<()>().await,
485                }
486            } => {
487                match state.authorizer().reload() {
488                    Ok(()) => state.metrics().record_policy_reload(),
489                    Err(error) => tracing::error!(%error, "policy reload failed after SIGHUP; retaining last known-good snapshot"),
490                }
491                tracing::info!(
492                    "SIGHUP received; cache invalidated, awaiting actual shutdown"
493                );
494                // Loop again — wait for SIGINT/SIGTERM.
495            }
496        }
497    }
498}
499
500/// Helper: phantom type for non-Unix branches where the signal futures
501/// can never resolve (signals don't exist on Windows).
502#[cfg(not(unix))]
503type Never = std::convert::Infallible;
504
505#[cfg(test)]
506mod tls_validation_tests {
507    use super::{
508        parse_audit_rotation, run, validate_grpc_listener, validate_listener_security,
509        validate_tls_paths,
510    };
511    use crate::auth_layer::AuthLayer;
512    use crate::listener::{AuthConfig, Listener, ServerConfig};
513    use std::io::Write;
514    use std::sync::Arc;
515
516    struct NoopReloadSink;
517
518    impl super::ReloadSink for NoopReloadSink {
519        fn reload(&self) {}
520    }
521
522    #[tokio::test]
523    async fn run_rejects_invalid_tls_material_before_loading_the_policy_store() {
524        let dir = tempfile::tempdir().unwrap();
525        let cfg = ServerConfig {
526            listener: Listener::Tls {
527                addr: "127.0.0.1:0".parse().unwrap(),
528                cert: dir.path().join("missing-cert.pem"),
529                key: dir.path().join("missing-key.pem"),
530            },
531            store_root: dir.path().join("missing-policy-store"),
532            audit_log: None,
533            chain_secret: None,
534            auth: AuthConfig::Disabled,
535            grpc_listener: None,
536        };
537
538        let error = run(cfg).await.unwrap_err().to_string();
539        assert!(
540            error.contains("tls cert"),
541            "unexpected startup error: {error}"
542        );
543        assert!(!dir.path().join("missing-policy-store").exists());
544    }
545
546    #[tokio::test]
547    async fn run_rejects_empty_audit_chain_secret_before_loading_the_policy_store() {
548        let dir = tempfile::tempdir().unwrap();
549        let secret = dir.path().join("chain-secret");
550        std::fs::write(&secret, b"").unwrap();
551        let cfg = ServerConfig {
552            listener: Listener::Tcp("127.0.0.1:0".parse().unwrap()),
553            store_root: dir.path().join("missing-policy-store"),
554            audit_log: Some(dir.path().join("audit.jsonl")),
555            chain_secret: Some(secret),
556            auth: AuthConfig::Disabled,
557            grpc_listener: None,
558        };
559
560        let error = run(cfg).await.unwrap_err().to_string();
561        assert!(
562            error.contains("chain secret file"),
563            "unexpected startup error: {error}"
564        );
565        assert!(!dir.path().join("missing-policy-store").exists());
566    }
567
568    #[test]
569    fn missing_cert_reports_helpful_error() {
570        let dir = tempfile::tempdir().unwrap();
571        let cert = dir.path().join("does-not-exist.pem");
572        let key = dir.path().join("also-missing.pem");
573        let err = validate_tls_paths(&cert, &key).unwrap_err().to_string();
574        assert!(err.contains("tls cert"), "missing label: {err}");
575        assert!(
576            err.contains("not readable"),
577            "missing actionable reason: {err}"
578        );
579    }
580
581    #[test]
582    fn empty_cert_reports_helpful_error() {
583        let dir = tempfile::tempdir().unwrap();
584        let cert_path = dir.path().join("cert.pem");
585        let key_path = dir.path().join("key.pem");
586        std::fs::File::create(&cert_path).unwrap();
587        std::fs::File::create(&key_path)
588            .unwrap()
589            .write_all(b"x")
590            .unwrap();
591        let err = validate_tls_paths(&cert_path, &key_path)
592            .unwrap_err()
593            .to_string();
594        assert!(err.contains("cert"), "missing cert label: {err}");
595        assert!(err.contains("empty"), "missing size reason: {err}");
596    }
597
598    #[test]
599    fn directory_instead_of_file_reports_helpful_error() {
600        let dir = tempfile::tempdir().unwrap();
601        let err = validate_tls_paths(dir.path(), dir.path())
602            .unwrap_err()
603            .to_string();
604        assert!(
605            err.contains("not a regular file"),
606            "missing dir reason: {err}"
607        );
608    }
609
610    #[test]
611    fn plaintext_grpc_listener_is_restricted_to_loopback() {
612        assert!(validate_grpc_listener("127.0.0.1:9443".parse().unwrap()).is_ok());
613        assert!(validate_grpc_listener("[::1]:9443".parse().unwrap()).is_ok());
614        let err = validate_grpc_listener("0.0.0.0:9443".parse().unwrap())
615            .unwrap_err()
616            .to_string();
617        assert!(err.contains("loopback-bound"));
618    }
619
620    #[test]
621    fn audit_rotation_requires_a_positive_integer_when_configured() {
622        assert!(parse_audit_rotation(None).unwrap().is_none());
623        assert_eq!(
624            parse_audit_rotation(Some("1048576"))
625                .unwrap()
626                .unwrap()
627                .max_bytes,
628            1_048_576
629        );
630        assert!(parse_audit_rotation(Some("0")).is_err());
631        assert!(parse_audit_rotation(Some("many")).is_err());
632    }
633
634    #[test]
635    fn fallible_policy_watcher_rejects_missing_store_root() {
636        let dir = tempfile::tempdir().unwrap();
637        let error = super::try_spawn_policy_watcher(
638            dir.path().join("missing-store"),
639            Arc::new(NoopReloadSink),
640        )
641        .unwrap_err();
642        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
643    }
644
645    #[tokio::test]
646    async fn filesystem_watcher_reloads_nested_policy_files() {
647        use std::time::Duration;
648
649        let dir = tempfile::tempdir().unwrap();
650        let store = agentguard_core::PolicyStore::open(dir.path()).unwrap();
651        store
652            .write_policy("initial", "permit(principal, action, resource);")
653            .unwrap();
654        let state =
655            crate::authzen::build_state(dir.path().to_path_buf(), None, None, AuthLayer::Disabled)
656                .await
657                .unwrap();
658        assert_eq!(state.authorizer().policy_count(), 1);
659
660        let watcher =
661            super::try_spawn_policy_watcher(dir.path().to_path_buf(), Arc::new(state.clone()))
662                .unwrap();
663        tokio::time::sleep(Duration::from_millis(100)).await;
664        let nested_policies = store.policies_dir().join("team");
665        std::fs::create_dir_all(&nested_policies).unwrap();
666        std::fs::write(
667            nested_policies.join("second.cedar"),
668            "forbid(principal, action, resource);",
669        )
670        .unwrap();
671
672        tokio::time::timeout(Duration::from_secs(4), async {
673            while state.authorizer().policy_count() != 2 {
674                tokio::time::sleep(Duration::from_millis(50)).await;
675            }
676        })
677        .await
678        .expect("nested subdirectory policy edit should reload the complete policy snapshot");
679        watcher.abort();
680    }
681
682    #[test]
683    fn unauthenticated_public_listener_requires_explicit_bypass() {
684        let listener = Listener::Tcp("0.0.0.0:8443".parse().unwrap());
685        let error =
686            validate_listener_security(&listener, None, &AuthConfig::Disabled, false).unwrap_err();
687        assert!(error.to_string().contains("auth is disabled"));
688    }
689
690    #[test]
691    fn explicit_bypass_only_relaxes_http_auth_guard() {
692        let listener = Listener::Tcp("0.0.0.0:8443".parse().unwrap());
693        assert!(validate_listener_security(&listener, None, &AuthConfig::Disabled, true).is_ok());
694        assert!(validate_listener_security(
695            &listener,
696            Some("0.0.0.0:9443".parse().unwrap()),
697            &AuthConfig::Disabled,
698            true
699        )
700        .is_err());
701    }
702
703    #[test]
704    fn api_key_auth_allows_public_listener_without_bypass() {
705        let listener = Listener::Tcp("0.0.0.0:8443".parse().unwrap());
706        let auth = AuthConfig::ApiKey {
707            path: "keys.json".into(),
708        };
709        assert!(validate_listener_security(&listener, None, &auth, false).is_ok());
710    }
711}