embacle_server/auth.rs
1// ABOUTME: Bearer auth and startup posture for the REST API, delegated to dravr-tronc
2// ABOUTME: Only the EMBACLE_API_KEY name is embacle's; the mechanism is the fleet's
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Authentication for the OpenAI-compatible surface.
8//!
9//! This module used to carry its own copy of the middleware, the loopback test
10//! and the startup posture check. That copy is gone: `dravr-tronc` now serves
11//! all three to the whole fleet, and the version there is the one this file's
12//! design was lifted into. Keeping a second implementation would mean a fix
13//! landing in one and not the other, which is the failure the shared crate
14//! exists to prevent.
15//!
16//! What remains is the only part that is genuinely embacle's: the name of the
17//! variable holding the key.
18//!
19//! The wire shape is unchanged by the move. Both crates answer a refusal with
20//! `{"error":{"type":"authentication_error","message":"…"}}` — embacle's
21//! `OpenAI` envelope carries `param` and `code` as well, but both are `None`
22//! for an auth error and both are skipped when serialising, so the bytes a
23//! client sees are the same.
24
25use axum::extract::Request;
26use axum::middleware::Next;
27use axum::response::Response;
28use dravr_tronc::server::auth;
29
30pub use dravr_tronc::server::auth::{AuthMode, InsecureBindError};
31
32/// Environment variable holding the API key.
33const API_KEY_ENV: &str = "EMBACLE_API_KEY";
34
35/// Validate the bearer token against `EMBACLE_API_KEY`.
36///
37/// The variable is read on every request, so a key can be rotated without a
38/// restart. Unset means every request passes — development mode, and the
39/// reason [`resolve_startup_auth`] exists to refuse that on a reachable bind.
40pub async fn require_auth(request: Request, next: Next) -> Response {
41 auth::require_auth(API_KEY_ENV, request, next).await
42}
43
44/// Whether a usable key is configured.
45#[must_use]
46pub fn api_key_configured() -> bool {
47 auth::api_key_configured(API_KEY_ENV)
48}
49
50/// Resolve the startup posture for a bind, or refuse it.
51///
52/// embacle is the guardian gate, so it fails closed: exposing tool execution on
53/// a reachable interface with nothing authenticating it is never permitted.
54///
55/// # Errors
56///
57/// Returns [`InsecureBindError`] when no key is set and `host` is not loopback.
58pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
59 auth::resolve_startup_auth(host, gated)
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65 use std::env;
66
67 #[test]
68 fn the_key_variable_is_embacles_own() {
69 // The one thing this module still owns. Everything else is tronc's.
70 assert_eq!(API_KEY_ENV, "EMBACLE_API_KEY");
71 }
72
73 #[test]
74 fn a_reachable_bind_with_nothing_gating_it_is_still_refused() {
75 // The posture this crate had before the move, asserted through the
76 // delegation so a tronc change that weakened it would fail here.
77 env::remove_var(API_KEY_ENV);
78 assert!(!api_key_configured());
79
80 let err = resolve_startup_auth("0.0.0.0", api_key_configured())
81 .expect_err("a reachable bind with no key must be refused"); // Safe: test assertion
82 assert_eq!(err.host, "0.0.0.0");
83
84 assert_eq!(
85 resolve_startup_auth("127.0.0.1", api_key_configured()),
86 Ok(AuthMode::LoopbackDev),
87 "loopback without a key is development, not a refusal"
88 );
89
90 env::set_var(API_KEY_ENV, "k");
91 assert!(api_key_configured());
92 assert_eq!(
93 resolve_startup_auth("0.0.0.0", api_key_configured()),
94 Ok(AuthMode::Enforced),
95 "a key enforces on every host"
96 );
97 env::remove_var(API_KEY_ENV);
98 }
99}