Skip to main content

assay_engine/
server.rs

1//! HTTP server wiring — composes the workflow API + dashboard + auth
2//! routers into one axum `Router`. URL surface:
3//!
4//! - `/auth/*`                   OIDC spec (discovery, authorize, token, …)
5//! - `/api/v1/engine/core/*`     engine-core admin (admin-bearer-gated)
6//! - `/api/v1/engine/workflow/*` workflow API (admin-bearer-gated)
7//! - `/api/v1/engine/auth/*`     engine-internal auth + admin
8//! - `/api/v1/vault/*`           vault module (admin-bearer-gated)
9//! - `/v1/*`                     Vault/OpenBao KV2 read facade (opt-in)
10//! - `/healthz`                  redirect to `/api/v1/engine/core/health`
11//!
12//! Per the decoupled-modules architecture: each module accepts ONLY
13//! an admin bearer token at its HTTP boundary. Per-user authentication
14//! and policy decisions live upstream of the engine — typically in a
15//! dashboard / BFF / API gateway that validates the user session, asks
16//! zanzibar if they're allowed, and then forwards the call to the
17//! engine using its own admin bearer. The engine itself does not
18//! resolve sessions or check zanzibar at request time.
19//!
20//! Share-redeem (`GET /api/v1/vault/share/{token}`) is the one route
21//! that bypasses admin bearer — the biscuit token in the URL is its
22//! own auth, verified inside the handler.
23
24use axum::Router;
25use axum::http::{HeaderMap, StatusCode, header};
26use axum::response::{IntoResponse, Redirect, Response};
27use axum::routing::get;
28use std::sync::Arc;
29use tracing::info;
30
31use assay_domain::events::EngineEventBus;
32use assay_workflow::events::WorkflowEventBus;
33use assay_workflow::{WorkflowCtx, WorkflowStore};
34
35use crate::state::EngineState;
36
37/// Compose the full `axum::Router` for the engine.
38///
39/// The workflow crate returns a `Router` that already embeds its state,
40/// and the dashboard crate returns a `Router<Arc<DashboardCtx>>` that we
41/// `.with_state()` here. Both are merged into a single stateless `Router`
42/// ready for `axum::serve`. When the `auth` feature is on AND the
43/// engine boot constructed an `AuthCtx`, the OIDC spec router (mounted
44/// at `/auth/`) and the engine-internal auth router (mounted under
45/// `/api/v1/engine/auth/`) join the composition.
46pub fn build_app<S: WorkflowStore + Clone + 'static>(state: EngineState<S>) -> Router {
47    let operator_ui_enabled = state.engine_config.dashboard.operator_enabled();
48    let auth_ui_enabled = state.engine_config.dashboard.auth_ui_enabled();
49    // Workflow router takes a non-optional gate closure (typechecked).
50    // We supply admin_bearer_middleware as the gate; the workflow
51    // crate applies it to only the authed portion of the router so
52    // /health, /version, /openapi.json, /docs stay public for probes.
53    let state_for_workflow = state.clone();
54    let workflow_router = assay_workflow::api::router(Arc::clone(&state.workflow), |r| {
55        r.layer(axum::middleware::from_fn_with_state(
56            state_for_workflow,
57            admin_bearer_middleware::<S>,
58        ))
59    });
60
61    // `/healthz` is kept as a 1-line redirect to the new engine-core
62    // health endpoint for backward-compatible k8s probes. The real
63    // health response is served by the engine-core router under
64    // `/api/v1/engine/core/health` (see `engine_api.rs`).
65    let healthz = Router::new().route(
66        "/healthz",
67        get(|| async { Redirect::permanent("/api/v1/engine/core/health") }),
68    );
69
70    // Engine-core admin API. The handlers require an admin api-key
71    // bearer; when `admin_api_keys` is empty every admin route returns
72    // 401, so mounting unconditionally is safe for no-auth builds.
73    let engine_api_router = crate::engine_api::router::<S>().with_state(state.clone());
74
75    let mut app = workflow_router.merge(healthz).merge(engine_api_router);
76
77    // Built-in operator SPAs. The engine ships its own browser UI —
78    // auth console, vault console, workflow dashboard, engine console —
79    // so a stand-alone deployment (no sysops in front) is usable from
80    // a browser. SPAs prompt for an admin api-key and store it in
81    // localStorage, then call the engine API with that bearer.
82    // Deployments fronting the engine with sysops/gondor (or any
83    // other dashboard) toggle this off at runtime with
84    // `[dashboard] enabled = false` in engine.toml.
85    if operator_ui_enabled {
86        let dashboard_router =
87            assay_dashboard::workflow_router().with_state(Arc::clone(&state.dashboard));
88        let engine_console_router = assay_dashboard::engine_router();
89        app = app.merge(dashboard_router).merge(engine_console_router);
90    }
91
92    // Mount the auth routers when AuthCtx is present. We bind state to
93    // each router *before* nesting so the merged tree remains
94    // `Router<()>` (every other sub-router has its state baked in
95    // similarly). This avoids the axum requirement that all merged
96    // routers share a common state parameter.
97    //
98    // The routers are generic over a parent state from which both
99    // `AuthCtx` and `AdminApiKeys` are extractable via `FromRef`;
100    // `EngineState<S>` implements both impls (see `state.rs`), so the
101    // engine threads its full state in once and the auth handlers
102    // pluck what they need.
103    if state.auth.is_some() {
104        // OIDC spec endpoints — mounted at `/auth/...`. Discovery doc,
105        // JWKS, authorize/token/userinfo/revoke/introspect/logout,
106        // federation upstream callbacks. Stable surface that downstream
107        // OIDC clients depend on.
108        let spec_router =
109            assay_auth::oidc_spec_router::<EngineState<S>>().with_state(state.clone());
110        app = app.nest("/auth", spec_router);
111
112        // Engine-internal auth — login, logout (DELETE), whoami,
113        // passkey ceremonies, admin (users/sessions/biscuit/jwks/
114        // zanzibar/audit + OIDC clients/upstream CRUD). Mounted under
115        // `/api/v1/engine/auth/...` so the operator-facing surface
116        // sits beside the engine-core + workflow APIs.
117        let engine_auth_router =
118            assay_auth::engine_auth_router::<EngineState<S>>().with_state(state.clone());
119        app = app.nest("/api/v1/engine/auth", engine_auth_router);
120
121        // Browser sign-in/recovery and the operator auth console are
122        // independently gated so a public issuer does not need to expose
123        // administrative HTML.
124        if auth_ui_enabled {
125            app = app.merge(assay_dashboard::auth_public_router());
126        }
127        if operator_ui_enabled {
128            app = app.merge(assay_dashboard::auth_console_router());
129        }
130    }
131
132    // Vault module — plan 17 / v0.3.0. Mounted under /api/v1/vault when
133    // both the Cargo feature is on AND a VaultCtx was composed at boot
134    // (i.e. engine.modules.vault.enabled was TRUE). Phase 1 routes are
135    // admin-key-gated; Phase 3+ adds biscuit-share and Phase 7 the
136    // BW-compat shim's per-user session auth.
137    #[cfg(feature = "vault")]
138    if state.vault.is_some() {
139        // Vault router requires a non-optional gate closure
140        // (typechecked). We supply admin_bearer_middleware, which has
141        // the share-redeem path bypass built in so /share/{token}
142        // stays public (biscuit verifies in the handler).
143        let state_for_vault = state.clone();
144        let vault = assay_vault::router::vault_router::<EngineState<S>, _>(|r| {
145            r.layer(axum::middleware::from_fn_with_state(
146                state_for_vault,
147                admin_bearer_middleware::<S>,
148            ))
149        })
150        .with_state(state.clone());
151        app = app.nest("/api/v1/vault", vault);
152
153        // Vault console SPA at /vault, /vault/console, /vault/console/*.
154        // Runtime-gated on the dashboard flag, same as the other SPAs.
155        if operator_ui_enabled {
156            app = app.merge(assay_dashboard::vault_router());
157        }
158    }
159
160    #[cfg(all(feature = "vault", feature = "vault-hashicorp-compat"))]
161    if state.vault.is_some() && state.engine_config.vault.hashicorp_compat.enabled {
162        app = app.merge(hashicorp_compat_router(&state));
163    }
164
165    // BW-compat shim (Phase 7). Stock BW mobile / browser / CLI
166    // clients hardcode /identity/* and /api/* — mount the compat
167    // router at root so those clients work without a reverse-proxy
168    // rewrite. Only reachable when both vault + bitwarden-compat
169    // features are on AND VaultCtx + AuthCtx are composed.
170    #[cfg(all(feature = "vault", feature = "vault-bitwarden-compat"))]
171    if state.vault.is_some() && state.auth.is_some() {
172        let bw =
173            assay_vault::bitwarden_compat::router::<EngineState<S>>().with_state(state.clone());
174        app = app.merge(bw);
175    }
176
177    if auth_ui_enabled && !operator_ui_enabled {
178        let auth_url = state
179            .engine_config
180            .auth
181            .public_url
182            .as_deref()
183            .unwrap_or(&state.engine_config.server.public_url);
184        let auth_host = url::Url::parse(auth_url)
185            .ok()
186            .and_then(|url| url.host_str().map(str::to_owned));
187        let root = Router::new()
188            .route("/", get(auth_origin_root))
189            .with_state(auth_host);
190        app = app.merge(root);
191    }
192
193    if !state.engine_config.server.allowed_hosts.is_empty() {
194        app = app.layer(axum::middleware::from_fn_with_state(
195            state,
196            allowed_host_middleware::<S>,
197        ));
198    }
199
200    app
201}
202
203/// Vault / OpenBao KV2 read facade, mounted at the router root because Vault
204/// clients (ESO, ansible, curl) hardcode `/v1/…` and cannot be told to use a
205/// prefix. It carries the same admin-bearer gate every other module surface
206/// does, translating `X-Vault-Token` into that bearer on the way in.
207#[cfg(all(feature = "vault", feature = "vault-hashicorp-compat"))]
208fn hashicorp_compat_router<S: WorkflowStore + Clone + 'static>(state: &EngineState<S>) -> Router {
209    let compat = &state.engine_config.vault.hashicorp_compat;
210    let mount = assay_vault::hashicorp_compat::Mount::new(&compat.mount);
211    let state_for_gate = state.clone();
212    assay_vault::hashicorp_compat::router::<EngineState<S>, _>(mount, |r| {
213        r.layer(axum::middleware::from_fn_with_state(
214            state_for_gate,
215            admin_bearer_middleware::<S>,
216        ))
217    })
218    .with_state(state.clone())
219}
220
221async fn auth_origin_root(
222    axum::extract::State(auth_host): axum::extract::State<Option<String>>,
223    headers: HeaderMap,
224) -> Response {
225    let request_host = request_host(&headers);
226    if auth_host
227        .as_deref()
228        .zip(request_host.as_deref())
229        .is_some_and(|(expected, actual)| expected.eq_ignore_ascii_case(actual))
230    {
231        return Redirect::temporary("/auth/landing").into_response();
232    }
233    StatusCode::NOT_FOUND.into_response()
234}
235
236async fn allowed_host_middleware<S: WorkflowStore + Clone + 'static>(
237    axum::extract::State(state): axum::extract::State<EngineState<S>>,
238    request: axum::extract::Request,
239    next: axum::middleware::Next,
240) -> Response {
241    if request.uri().path() == "/api/v1/engine/core/health"
242        || host_is_allowed(request.headers(), &state.engine_config.server.allowed_hosts)
243    {
244        return next.run(request).await;
245    }
246    StatusCode::MISDIRECTED_REQUEST.into_response()
247}
248
249fn host_is_allowed(headers: &HeaderMap, allowed_hosts: &[String]) -> bool {
250    if allowed_hosts.is_empty() {
251        return true;
252    }
253    let Some(host) = request_host(headers) else {
254        return false;
255    };
256    allowed_hosts
257        .iter()
258        .any(|allowed| allowed.eq_ignore_ascii_case(&host))
259}
260
261fn request_host(headers: &HeaderMap) -> Option<String> {
262    let value = headers.get(header::HOST)?.to_str().ok()?;
263    value
264        .parse::<axum::http::uri::Authority>()
265        .ok()
266        .map(|authority| authority.host().to_owned())
267}
268
269/// Resource-server middleware applied to every engine module router.
270/// Accepts EITHER the operator admin api-key (service-to-service /
271/// break-glass) OR a JWT from a configured trusted issuer (per-user
272/// resource-server pattern). No session, no zanzibar — policy lives
273/// upstream.
274///
275/// The one bypass: vault share-redeem (`GET /share/{token}` —
276/// path-relative because this middleware runs INSIDE the nested
277/// `/api/v1/vault` router, after axum has stripped the prefix).
278/// `/share/revoke` is excluded — it's an admin operation that mints
279/// or revokes tokens. The biscuit token in the share-redeem URL is
280/// its own authentication.
281async fn admin_bearer_middleware<S: WorkflowStore + Clone + 'static>(
282    axum::extract::State(state): axum::extract::State<EngineState<S>>,
283    request: axum::extract::Request,
284    next: axum::middleware::Next,
285) -> axum::response::Response {
286    let path = request.uri().path();
287    if (path.starts_with("/share/") && path != "/share/revoke")
288        || (path.starts_with("/api/v1/vault/share/") && path != "/api/v1/vault/share/revoke")
289    {
290        return next.run(request).await;
291    }
292    let keys = crate::state::AdminApiKeys(Arc::clone(&state.admin_api_keys));
293    // If auth is configured, accept admin bearer OR trusted JWT.
294    // If auth is not configured at all (no AuthCtx), only the admin
295    // bearer path is available — fall back to the strict check.
296    let outcome = match state.auth.as_ref() {
297        Some(auth) => assay_auth::gate::require_admin_or_jwt(request.headers(), auth, &keys)
298            .await
299            .map(|_| ()),
300        None => assay_auth::gate::require_admin_bearer(request.headers(), &keys),
301    };
302    if let Err(r) = outcome {
303        return *r;
304    }
305    next.run(request).await
306}
307
308/// Bind a TCP listener on `bind_addr` and serve the composed app.
309///
310/// Convenience wrapper that composes [`EngineState`] into an
311/// [`axum::Router`] via [`build_app`] and hands off to
312/// [`bind_and_serve`]. Used by the standalone `assay-engine` binary.
313pub async fn serve<S: WorkflowStore + Clone + 'static>(
314    bind_addr: &str,
315    state: EngineState<S>,
316) -> anyhow::Result<()> {
317    let app = build_app(state);
318    bind_and_serve(bind_addr, app).await
319}
320
321/// Bind a TCP listener on `bind_addr` and serve a pre-built
322/// [`axum::Router`].
323///
324/// Used by [`crate::run`] (after `embedded::build` returns the
325/// composed router) and by downstream embedders who want to add
326/// their own middleware / merge with their own router before
327/// serving.
328pub async fn bind_and_serve(bind_addr: &str, app: axum::Router) -> anyhow::Result<()> {
329    let listener = tokio::net::TcpListener::bind(bind_addr)
330        .await
331        .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
332    let actual = listener.local_addr()?;
333    info!(target: "assay-engine", %actual, "listening");
334    axum::serve(listener, app).await?;
335    Ok(())
336}
337
338/// Start a `WorkflowCtx` around the given store. Authentication +
339/// authorization are no longer the workflow's concern — the engine
340/// wraps the workflow router with a gate middleware ([`build_app`])
341/// that handles all of that.
342pub fn build_workflow_ctx<S: WorkflowStore + 'static>(store: S) -> Arc<WorkflowCtx<S>> {
343    let ctx = WorkflowCtx::start(Arc::new(store)).with_binary_version(env!("CARGO_PKG_VERSION"));
344    Arc::new(ctx)
345}
346
347/// Like [`build_workflow_ctx`] but also wires the engine-wide event
348/// bus into the workflow context so SSE + the dispatch-wakeup loop see
349/// state transitions.
350pub fn build_workflow_ctx_with_bus<S: WorkflowStore + 'static>(
351    store: S,
352    bus: Arc<dyn EngineEventBus>,
353) -> Arc<WorkflowCtx<S>> {
354    let ctx = WorkflowCtx::start(Arc::new(store))
355        .with_binary_version(env!("CARGO_PKG_VERSION"))
356        .with_event_bus(WorkflowEventBus::new(bus));
357    Arc::new(ctx)
358}
359
360#[cfg(test)]
361mod host_boundary_tests {
362    use axum::extract::State;
363    use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
364
365    use super::{auth_origin_root, host_is_allowed};
366
367    #[tokio::test]
368    async fn auth_origin_root_enters_public_auth_while_engine_root_stays_hidden() {
369        let mut auth_headers = HeaderMap::new();
370        auth_headers.insert(header::HOST, HeaderValue::from_static("auth.assay.rs"));
371        let auth = auth_origin_root(State(Some("auth.assay.rs".to_string())), auth_headers).await;
372        assert_eq!(auth.status(), StatusCode::TEMPORARY_REDIRECT);
373        assert_eq!(auth.headers()[header::LOCATION], "/auth/landing");
374
375        let mut engine_headers = HeaderMap::new();
376        engine_headers.insert(header::HOST, HeaderValue::from_static("engine.assay.rs"));
377        let engine =
378            auth_origin_root(State(Some("auth.assay.rs".to_string())), engine_headers).await;
379        assert_eq!(engine.status(), StatusCode::NOT_FOUND);
380    }
381
382    #[test]
383    fn configured_hosts_are_case_insensitive_and_port_agnostic() {
384        let allowed = vec!["auth.assay.rs".to_string(), "engine.assay.rs".to_string()];
385        let mut headers = HeaderMap::new();
386        headers.insert(header::HOST, HeaderValue::from_static("AUTH.ASSAY.RS:443"));
387
388        assert!(host_is_allowed(&headers, &allowed));
389    }
390
391    #[test]
392    fn unknown_and_missing_hosts_are_rejected_when_the_allowlist_is_configured() {
393        let allowed = vec!["auth.assay.rs".to_string(), "engine.assay.rs".to_string()];
394        let mut headers = HeaderMap::new();
395        headers.insert(header::HOST, HeaderValue::from_static("assay-auth.fly.dev"));
396
397        assert!(!host_is_allowed(&headers, &allowed));
398        assert!(!host_is_allowed(&HeaderMap::new(), &allowed));
399    }
400
401    #[test]
402    fn an_empty_allowlist_preserves_embedded_and_local_callers() {
403        assert!(host_is_allowed(&HeaderMap::new(), &[]));
404    }
405}