1use 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
37pub 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 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 let healthz = Router::new().route(
66 "/healthz",
67 get(|| async { Redirect::permanent("/api/v1/engine/core/health") }),
68 );
69
70 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 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 if state.auth.is_some() {
104 let spec_router =
109 assay_auth::oidc_spec_router::<EngineState<S>>().with_state(state.clone());
110 app = app.nest("/auth", spec_router);
111
112 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 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 #[cfg(feature = "vault")]
138 if state.vault.is_some() {
139 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 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 #[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#[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
269async 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 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
308pub 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
321pub 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
338pub 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
347pub 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}