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
6//! - `/api/v1/engine/workflow/*` workflow API (auth-gated below)
7//! - `/api/v1/engine/auth/*` engine-internal auth + admin
8//! - `/healthz` redirect to `/api/v1/engine/core/health`
9
10use axum::Router;
11use axum::response::Redirect;
12use axum::routing::get;
13use std::sync::Arc;
14use tracing::info;
15
16use assay_domain::events::EngineEventBus;
17use assay_workflow::events::WorkflowEventBus;
18use assay_workflow::{WorkflowCtx, WorkflowStore};
19
20use crate::state::EngineState;
21
22/// Always-public paths under `/api/v1/engine/workflow/*` that bypass
23/// the engine's auth gate (k8s probes, version banners, OpenAPI docs).
24const WORKFLOW_PUBLIC_PATHS: &[&str] = &[
25 "/api/v1/engine/workflow/health",
26 "/api/v1/engine/workflow/version",
27 "/api/v1/engine/workflow/openapi.json",
28 "/api/v1/engine/workflow/docs",
29];
30
31/// Compose the full `axum::Router` for the engine.
32///
33/// The workflow crate returns a `Router` that already embeds its state,
34/// and the dashboard crate returns a `Router<Arc<DashboardCtx>>` that we
35/// `.with_state()` here. Both are merged into a single stateless `Router`
36/// ready for `axum::serve`. When the `auth` feature is on AND the
37/// engine boot constructed an `AuthCtx`, the OIDC spec router (mounted
38/// at `/auth/`) and the engine-internal auth router (mounted under
39/// `/api/v1/engine/auth/`) join the composition.
40pub fn build_app<S: WorkflowStore + Clone + 'static>(state: EngineState<S>) -> Router {
41 // Workflow router carries no auth of its own — slice 2 lifted that
42 // to the engine layer. When `auth` is on AND an `AuthCtx` is
43 // composed, wrap the workflow router with the gate middleware that
44 // enforces `workflow:<namespace>#access` via `assay_auth::gate`.
45 let workflow_router = assay_workflow::api::router(Arc::clone(&state.workflow));
46 let workflow_router = if state.auth.is_some() {
47 workflow_router.layer(axum::middleware::from_fn_with_state(
48 state.clone(),
49 workflow_gate_middleware::<S>,
50 ))
51 } else {
52 workflow_router
53 };
54
55 let dashboard_router =
56 assay_dashboard::workflow_router().with_state(Arc::clone(&state.dashboard));
57
58 // `/healthz` is kept as a 1-line redirect to the new engine-core
59 // health endpoint for backward-compatible k8s probes. The real
60 // health response is served by the engine-core router under
61 // `/api/v1/engine/core/health` (see `engine_api.rs`).
62 let healthz = Router::new().route(
63 "/healthz",
64 get(|| async {
65 Redirect::permanent("/api/v1/engine/core/health")
66 }),
67 );
68
69 // Engine-core admin API + console SPA. Always present (engine-core
70 // is always running, regardless of which functional modules are
71 // enabled). The admin handlers require a configured api-key —
72 // when `admin_api_keys` is empty every admin route returns 401, so
73 // mounting unconditionally is safe for no-auth builds. The
74 // engine-core router carries `/api/v1/engine/core/info` (public),
75 // `/api/v1/engine/core/health`, `/api/v1/engine/core/active-modules`,
76 // and the admin endpoints.
77 let engine_api_router = crate::engine_api::router::<S>().with_state(state.clone());
78 let engine_console_router = assay_dashboard::engine_router();
79
80 let mut app = workflow_router
81 .merge(dashboard_router)
82 .merge(healthz)
83 .merge(engine_api_router)
84 .merge(engine_console_router);
85
86 // Mount the auth routers when AuthCtx is present. We bind state to
87 // each router *before* nesting so the merged tree remains
88 // `Router<()>` (every other sub-router has its state baked in
89 // similarly). This avoids the axum requirement that all merged
90 // routers share a common state parameter.
91 //
92 // The routers are generic over a parent state from which both
93 // `AuthCtx` and `AdminApiKeys` are extractable via `FromRef`;
94 // `EngineState<S>` implements both impls (see `state.rs`), so the
95 // engine threads its full state in once and the auth handlers
96 // pluck what they need.
97 if state.auth.is_some() {
98 // OIDC spec endpoints — mounted at `/auth/...`. Discovery doc,
99 // JWKS, authorize/token/userinfo/revoke/introspect/logout,
100 // federation upstream callbacks. Stable surface that downstream
101 // OIDC clients depend on.
102 let spec_router =
103 assay_auth::oidc_spec_router::<EngineState<S>>().with_state(state.clone());
104 app = app.nest("/auth", spec_router);
105
106 // Engine-internal auth — login, logout (DELETE), whoami,
107 // passkey ceremonies, admin (users/sessions/biscuit/jwks/
108 // zanzibar/audit + OIDC clients/upstream CRUD). Mounted under
109 // `/api/v1/engine/auth/...` so the operator-facing surface
110 // sits beside the engine-core + workflow APIs.
111 let engine_auth_router =
112 assay_auth::engine_auth_router::<EngineState<S>>().with_state(state.clone());
113 app = app.nest("/api/v1/engine/auth", engine_auth_router);
114
115 // Mount the auth-console SPA assets at root (so the same
116 // `/auth/...` path namespace serves both the OIDC spec and the
117 // dashboard asset bundle — `/auth/console` for the SPA,
118 // `/api/v1/engine/auth/admin/*` for the admin JSON API).
119 let asset_router = assay_dashboard::auth_router();
120 app = app.merge(asset_router);
121 }
122
123 // Vault module — plan 17 / v0.3.0. Mounted under /api/v1/vault when
124 // both the Cargo feature is on AND a VaultCtx was composed at boot
125 // (i.e. engine.modules.vault.enabled was TRUE). Phase 1 routes are
126 // admin-key-gated; Phase 3+ adds biscuit-share and Phase 7 the
127 // BW-compat shim's per-user session auth.
128 #[cfg(feature = "vault")]
129 if state.vault.is_some() {
130 let vault = assay_vault::router::vault_router::<EngineState<S>>()
131 .with_state(state.clone());
132 app = app.nest("/api/v1/vault", vault);
133 }
134
135 // BW-compat shim (Phase 7). Stock BW mobile / browser / CLI
136 // clients hardcode /identity/* and /api/* — mount the compat
137 // router at root so those clients work without a reverse-proxy
138 // rewrite. Only reachable when both vault + bitwarden-compat
139 // features are on AND VaultCtx + AuthCtx are composed.
140 #[cfg(all(feature = "vault", feature = "vault-bitwarden-compat"))]
141 if state.vault.is_some() && state.auth.is_some() {
142 let bw = assay_vault::bitwarden_compat::router::<EngineState<S>>()
143 .with_state(state.clone());
144 app = app.merge(bw);
145 }
146
147 // Vault console assets (plan 17 §S10). Always mounted when the
148 // vault feature is on; runtime visibility is gated by
149 // engine.modules.vault.enabled like every other console.
150 #[cfg(feature = "vault")]
151 {
152 app = app.merge(assay_dashboard::vault_router());
153 }
154
155 app
156}
157
158/// Workflow-API auth gate. The engine is the auth boundary for every
159/// module — the workflow router carries no gate of its own.
160/// [`WORKFLOW_PUBLIC_PATHS`] bypass; everything else goes through
161/// [`assay_auth::gate::require_role_for`] keyed on the
162/// `?namespace=<X>` query param (default `main`).
163async fn workflow_gate_middleware<S: WorkflowStore + Clone + 'static>(
164 axum::extract::State(state): axum::extract::State<EngineState<S>>,
165 request: axum::extract::Request,
166 next: axum::middleware::Next,
167) -> axum::response::Response {
168 let path = request.uri().path();
169 if WORKFLOW_PUBLIC_PATHS
170 .iter()
171 .any(|p| path == *p || path.starts_with(&format!("{p}/")))
172 {
173 return next.run(request).await;
174 }
175
176 // Auth is on but no `AuthCtx` was composed at boot — this can
177 // happen for tests that build a no-auth engine. Fail closed; the
178 // engine binary's boot path always composes an AuthCtx when the
179 // auth module is enabled.
180 let Some(auth) = state.auth.as_ref() else {
181 return (
182 axum::http::StatusCode::SERVICE_UNAVAILABLE,
183 axum::Json(serde_json::json!({
184 "error": "service_unavailable",
185 "error_description": "auth not configured on this engine instance",
186 })),
187 )
188 .into_response();
189 };
190
191 let namespace = request
192 .uri()
193 .query()
194 .and_then(|q| {
195 url::form_urlencoded::parse(q.as_bytes())
196 .find(|(k, _)| k == "namespace")
197 .map(|(_, v)| v.into_owned())
198 })
199 .unwrap_or_else(|| "main".to_string());
200
201 let keys = crate::state::AdminApiKeys(Arc::clone(&state.admin_api_keys));
202 let headers = request.headers().clone();
203 if let Err(r) = assay_auth::gate::require_role_for(
204 &headers,
205 auth,
206 &keys,
207 "workflow",
208 &namespace,
209 "access",
210 )
211 .await
212 {
213 return *r;
214 }
215
216 next.run(request).await
217}
218
219use axum::response::IntoResponse;
220
221/// Bind a TCP listener on `bind_addr` and serve the composed app.
222///
223/// Convenience wrapper that composes [`EngineState`] into an
224/// [`axum::Router`] via [`build_app`] and hands off to
225/// [`bind_and_serve`]. Used by the standalone `assay-engine` binary.
226pub async fn serve<S: WorkflowStore + Clone + 'static>(
227 bind_addr: &str,
228 state: EngineState<S>,
229) -> anyhow::Result<()> {
230 let app = build_app(state);
231 bind_and_serve(bind_addr, app).await
232}
233
234/// Bind a TCP listener on `bind_addr` and serve a pre-built
235/// [`axum::Router`].
236///
237/// Used by [`crate::run`] (after `embedded::build` returns the
238/// composed router) and by downstream embedders who want to add
239/// their own middleware / merge with their own router before
240/// serving.
241pub async fn bind_and_serve(
242 bind_addr: &str,
243 app: axum::Router,
244) -> anyhow::Result<()> {
245 let listener = tokio::net::TcpListener::bind(bind_addr)
246 .await
247 .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
248 let actual = listener.local_addr()?;
249 info!(target: "assay-engine", %actual, "listening");
250 axum::serve(listener, app).await?;
251 Ok(())
252}
253
254/// Start a `WorkflowCtx` around the given store. Authentication +
255/// authorization are no longer the workflow's concern — the engine
256/// wraps the workflow router with a gate middleware ([`build_app`])
257/// that handles all of that.
258pub fn build_workflow_ctx<S: WorkflowStore + 'static>(store: S) -> Arc<WorkflowCtx<S>> {
259 let ctx = WorkflowCtx::start(Arc::new(store)).with_binary_version(env!("CARGO_PKG_VERSION"));
260 Arc::new(ctx)
261}
262
263/// Like [`build_workflow_ctx`] but also wires the engine-wide event
264/// bus into the workflow context so SSE + the dispatch-wakeup loop see
265/// state transitions.
266pub fn build_workflow_ctx_with_bus<S: WorkflowStore + 'static>(
267 store: S,
268 bus: Arc<dyn EngineEventBus>,
269) -> Arc<WorkflowCtx<S>> {
270 let ctx = WorkflowCtx::start(Arc::new(store))
271 .with_binary_version(env!("CARGO_PKG_VERSION"))
272 .with_event_bus(WorkflowEventBus::new(bus));
273 Arc::new(ctx)
274}
275