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//! - `/healthz`                  redirect to `/api/v1/engine/core/health`
10//!
11//! Per the decoupled-modules architecture: each module accepts ONLY
12//! an admin bearer token at its HTTP boundary. Per-user authentication
13//! and policy decisions live upstream of the engine — typically in a
14//! dashboard / BFF / API gateway that validates the user session, asks
15//! zanzibar if they're allowed, and then forwards the call to the
16//! engine using its own admin bearer. The engine itself does not
17//! resolve sessions or check zanzibar at request time.
18//!
19//! Share-redeem (`GET /api/v1/vault/share/{token}`) is the one route
20//! that bypasses admin bearer — the biscuit token in the URL is its
21//! own auth, verified inside the handler.
22
23use axum::Router;
24use axum::response::Redirect;
25use axum::routing::get;
26use std::sync::Arc;
27use tracing::info;
28
29use assay_domain::events::EngineEventBus;
30use assay_workflow::events::WorkflowEventBus;
31use assay_workflow::{WorkflowCtx, WorkflowStore};
32
33use crate::state::EngineState;
34
35/// Compose the full `axum::Router` for the engine.
36///
37/// The workflow crate returns a `Router` that already embeds its state,
38/// and the dashboard crate returns a `Router<Arc<DashboardCtx>>` that we
39/// `.with_state()` here. Both are merged into a single stateless `Router`
40/// ready for `axum::serve`. When the `auth` feature is on AND the
41/// engine boot constructed an `AuthCtx`, the OIDC spec router (mounted
42/// at `/auth/`) and the engine-internal auth router (mounted under
43/// `/api/v1/engine/auth/`) join the composition.
44pub fn build_app<S: WorkflowStore + Clone + 'static>(state: EngineState<S>) -> Router {
45    // Workflow router takes a non-optional gate closure (typechecked).
46    // We supply admin_bearer_middleware as the gate; the workflow
47    // crate applies it to only the authed portion of the router so
48    // /health, /version, /openapi.json, /docs stay public for probes.
49    let state_for_workflow = state.clone();
50    let workflow_router = assay_workflow::api::router(Arc::clone(&state.workflow), |r| {
51        r.layer(axum::middleware::from_fn_with_state(
52            state_for_workflow,
53            admin_bearer_middleware::<S>,
54        ))
55    });
56
57    // `/healthz` is kept as a 1-line redirect to the new engine-core
58    // health endpoint for backward-compatible k8s probes. The real
59    // health response is served by the engine-core router under
60    // `/api/v1/engine/core/health` (see `engine_api.rs`).
61    let healthz = Router::new().route(
62        "/healthz",
63        get(|| async { Redirect::permanent("/api/v1/engine/core/health") }),
64    );
65
66    // Engine-core admin API. The handlers require an admin api-key
67    // bearer; when `admin_api_keys` is empty every admin route returns
68    // 401, so mounting unconditionally is safe for no-auth builds.
69    let engine_api_router = crate::engine_api::router::<S>().with_state(state.clone());
70
71    let mut app = workflow_router.merge(healthz).merge(engine_api_router);
72
73    // Built-in operator SPAs. The engine ships its own browser UI —
74    // auth console, vault console, workflow dashboard, engine console —
75    // so a stand-alone deployment (no sysops in front) is usable from
76    // a browser. SPAs prompt for an admin api-key and store it in
77    // localStorage, then call the engine API with that bearer.
78    // Deployments fronting the engine with sysops/gondor (or any
79    // other dashboard) toggle this off at runtime with
80    // `[dashboard] enabled = false` in engine.toml.
81    if state.engine_config.dashboard.enabled {
82        let dashboard_router =
83            assay_dashboard::workflow_router().with_state(Arc::clone(&state.dashboard));
84        let engine_console_router = assay_dashboard::engine_router();
85        app = app.merge(dashboard_router).merge(engine_console_router);
86    }
87
88    // Mount the auth routers when AuthCtx is present. We bind state to
89    // each router *before* nesting so the merged tree remains
90    // `Router<()>` (every other sub-router has its state baked in
91    // similarly). This avoids the axum requirement that all merged
92    // routers share a common state parameter.
93    //
94    // The routers are generic over a parent state from which both
95    // `AuthCtx` and `AdminApiKeys` are extractable via `FromRef`;
96    // `EngineState<S>` implements both impls (see `state.rs`), so the
97    // engine threads its full state in once and the auth handlers
98    // pluck what they need.
99    if state.auth.is_some() {
100        // OIDC spec endpoints — mounted at `/auth/...`. Discovery doc,
101        // JWKS, authorize/token/userinfo/revoke/introspect/logout,
102        // federation upstream callbacks. Stable surface that downstream
103        // OIDC clients depend on.
104        let spec_router =
105            assay_auth::oidc_spec_router::<EngineState<S>>().with_state(state.clone());
106        app = app.nest("/auth", spec_router);
107
108        // Engine-internal auth — login, logout (DELETE), whoami,
109        // passkey ceremonies, admin (users/sessions/biscuit/jwks/
110        // zanzibar/audit + OIDC clients/upstream CRUD). Mounted under
111        // `/api/v1/engine/auth/...` so the operator-facing surface
112        // sits beside the engine-core + workflow APIs.
113        let engine_auth_router =
114            assay_auth::engine_auth_router::<EngineState<S>>().with_state(state.clone());
115        app = app.nest("/api/v1/engine/auth", engine_auth_router);
116
117        // Auth-console SPA + /auth/login. Runtime-gated on
118        // `[dashboard] enabled = true`. Deployments that turn the
119        // dashboard off also lose /auth/login (so they can't serve
120        // OIDC authorization-code redirects directly to a browser),
121        // which is the price of running engine as a pure API.
122        if state.engine_config.dashboard.enabled {
123            let asset_router = assay_dashboard::auth_router();
124            app = app.merge(asset_router);
125        }
126    }
127
128    // Vault module — plan 17 / v0.3.0. Mounted under /api/v1/vault when
129    // both the Cargo feature is on AND a VaultCtx was composed at boot
130    // (i.e. engine.modules.vault.enabled was TRUE). Phase 1 routes are
131    // admin-key-gated; Phase 3+ adds biscuit-share and Phase 7 the
132    // BW-compat shim's per-user session auth.
133    #[cfg(feature = "vault")]
134    if state.vault.is_some() {
135        // Vault router requires a non-optional gate closure
136        // (typechecked). We supply admin_bearer_middleware, which has
137        // the share-redeem path bypass built in so /share/{token}
138        // stays public (biscuit verifies in the handler).
139        let state_for_vault = state.clone();
140        let vault = assay_vault::router::vault_router::<EngineState<S>, _>(|r| {
141            r.layer(axum::middleware::from_fn_with_state(
142                state_for_vault,
143                admin_bearer_middleware::<S>,
144            ))
145        })
146        .with_state(state.clone());
147        app = app.nest("/api/v1/vault", vault);
148
149        // Vault console SPA at /vault, /vault/console, /vault/console/*.
150        // Runtime-gated on the dashboard flag, same as the other SPAs.
151        if state.engine_config.dashboard.enabled {
152            app = app.merge(assay_dashboard::vault_router());
153        }
154    }
155
156    // BW-compat shim (Phase 7). Stock BW mobile / browser / CLI
157    // clients hardcode /identity/* and /api/* — mount the compat
158    // router at root so those clients work without a reverse-proxy
159    // rewrite. Only reachable when both vault + bitwarden-compat
160    // features are on AND VaultCtx + AuthCtx are composed.
161    #[cfg(all(feature = "vault", feature = "vault-bitwarden-compat"))]
162    if state.vault.is_some() && state.auth.is_some() {
163        let bw =
164            assay_vault::bitwarden_compat::router::<EngineState<S>>().with_state(state.clone());
165        app = app.merge(bw);
166    }
167
168    app
169}
170
171/// Resource-server middleware applied to every engine module router.
172/// Accepts EITHER the operator admin api-key (service-to-service /
173/// break-glass) OR a JWT from a configured trusted issuer (per-user
174/// resource-server pattern). No session, no zanzibar — policy lives
175/// upstream.
176///
177/// The one bypass: vault share-redeem (`GET /share/{token}` —
178/// path-relative because this middleware runs INSIDE the nested
179/// `/api/v1/vault` router, after axum has stripped the prefix).
180/// `/share/revoke` is excluded — it's an admin operation that mints
181/// or revokes tokens. The biscuit token in the share-redeem URL is
182/// its own authentication.
183async fn admin_bearer_middleware<S: WorkflowStore + Clone + 'static>(
184    axum::extract::State(state): axum::extract::State<EngineState<S>>,
185    request: axum::extract::Request,
186    next: axum::middleware::Next,
187) -> axum::response::Response {
188    let path = request.uri().path();
189    if (path.starts_with("/share/") && path != "/share/revoke")
190        || (path.starts_with("/api/v1/vault/share/") && path != "/api/v1/vault/share/revoke")
191    {
192        return next.run(request).await;
193    }
194    let keys = crate::state::AdminApiKeys(Arc::clone(&state.admin_api_keys));
195    // If auth is configured, accept admin bearer OR trusted JWT.
196    // If auth is not configured at all (no AuthCtx), only the admin
197    // bearer path is available — fall back to the strict check.
198    let outcome = match state.auth.as_ref() {
199        Some(auth) => assay_auth::gate::require_admin_or_jwt(request.headers(), auth, &keys)
200            .await
201            .map(|_| ()),
202        None => assay_auth::gate::require_admin_bearer(request.headers(), &keys),
203    };
204    if let Err(r) = outcome {
205        return *r;
206    }
207    next.run(request).await
208}
209
210/// Bind a TCP listener on `bind_addr` and serve the composed app.
211///
212/// Convenience wrapper that composes [`EngineState`] into an
213/// [`axum::Router`] via [`build_app`] and hands off to
214/// [`bind_and_serve`]. Used by the standalone `assay-engine` binary.
215pub async fn serve<S: WorkflowStore + Clone + 'static>(
216    bind_addr: &str,
217    state: EngineState<S>,
218) -> anyhow::Result<()> {
219    let app = build_app(state);
220    bind_and_serve(bind_addr, app).await
221}
222
223/// Bind a TCP listener on `bind_addr` and serve a pre-built
224/// [`axum::Router`].
225///
226/// Used by [`crate::run`] (after `embedded::build` returns the
227/// composed router) and by downstream embedders who want to add
228/// their own middleware / merge with their own router before
229/// serving.
230pub async fn bind_and_serve(bind_addr: &str, app: axum::Router) -> anyhow::Result<()> {
231    let listener = tokio::net::TcpListener::bind(bind_addr)
232        .await
233        .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
234    let actual = listener.local_addr()?;
235    info!(target: "assay-engine", %actual, "listening");
236    axum::serve(listener, app).await?;
237    Ok(())
238}
239
240/// Start a `WorkflowCtx` around the given store. Authentication +
241/// authorization are no longer the workflow's concern — the engine
242/// wraps the workflow router with a gate middleware ([`build_app`])
243/// that handles all of that.
244pub fn build_workflow_ctx<S: WorkflowStore + 'static>(store: S) -> Arc<WorkflowCtx<S>> {
245    let ctx = WorkflowCtx::start(Arc::new(store)).with_binary_version(env!("CARGO_PKG_VERSION"));
246    Arc::new(ctx)
247}
248
249/// Like [`build_workflow_ctx`] but also wires the engine-wide event
250/// bus into the workflow context so SSE + the dispatch-wakeup loop see
251/// state transitions.
252pub fn build_workflow_ctx_with_bus<S: WorkflowStore + 'static>(
253    store: S,
254    bus: Arc<dyn EngineEventBus>,
255) -> Arc<WorkflowCtx<S>> {
256    let ctx = WorkflowCtx::start(Arc::new(store))
257        .with_binary_version(env!("CARGO_PKG_VERSION"))
258        .with_event_bus(WorkflowEventBus::new(bus));
259    Arc::new(ctx)
260}