1use axum::Router;
24use axum::http::{HeaderMap, StatusCode, header};
25use axum::response::{IntoResponse, Redirect, Response};
26use axum::routing::get;
27use std::sync::Arc;
28use tracing::info;
29
30use assay_domain::events::EngineEventBus;
31use assay_workflow::events::WorkflowEventBus;
32use assay_workflow::{WorkflowCtx, WorkflowStore};
33
34use crate::state::EngineState;
35
36pub fn build_app<S: WorkflowStore + Clone + 'static>(state: EngineState<S>) -> Router {
46 let operator_ui_enabled = state.engine_config.dashboard.operator_enabled();
47 let auth_ui_enabled = state.engine_config.dashboard.auth_ui_enabled();
48 let state_for_workflow = state.clone();
53 let workflow_router = assay_workflow::api::router(Arc::clone(&state.workflow), |r| {
54 r.layer(axum::middleware::from_fn_with_state(
55 state_for_workflow,
56 admin_bearer_middleware::<S>,
57 ))
58 });
59
60 let healthz = Router::new().route(
65 "/healthz",
66 get(|| async { Redirect::permanent("/api/v1/engine/core/health") }),
67 );
68
69 let engine_api_router = crate::engine_api::router::<S>().with_state(state.clone());
73
74 let mut app = workflow_router.merge(healthz).merge(engine_api_router);
75
76 if operator_ui_enabled {
85 let dashboard_router =
86 assay_dashboard::workflow_router().with_state(Arc::clone(&state.dashboard));
87 let engine_console_router = assay_dashboard::engine_router();
88 app = app.merge(dashboard_router).merge(engine_console_router);
89 }
90
91 if state.auth.is_some() {
103 let spec_router =
108 assay_auth::oidc_spec_router::<EngineState<S>>().with_state(state.clone());
109 app = app.nest("/auth", spec_router);
110
111 let engine_auth_router =
117 assay_auth::engine_auth_router::<EngineState<S>>().with_state(state.clone());
118 app = app.nest("/api/v1/engine/auth", engine_auth_router);
119
120 if auth_ui_enabled {
124 app = app.merge(assay_dashboard::auth_public_router());
125 }
126 if operator_ui_enabled {
127 app = app.merge(assay_dashboard::auth_console_router());
128 }
129 }
130
131 #[cfg(feature = "vault")]
137 if state.vault.is_some() {
138 let state_for_vault = state.clone();
143 let vault = assay_vault::router::vault_router::<EngineState<S>, _>(|r| {
144 r.layer(axum::middleware::from_fn_with_state(
145 state_for_vault,
146 admin_bearer_middleware::<S>,
147 ))
148 })
149 .with_state(state.clone());
150 app = app.nest("/api/v1/vault", vault);
151
152 if operator_ui_enabled {
155 app = app.merge(assay_dashboard::vault_router());
156 }
157 }
158
159 #[cfg(all(feature = "vault", feature = "vault-bitwarden-compat"))]
165 if state.vault.is_some() && state.auth.is_some() {
166 let bw =
167 assay_vault::bitwarden_compat::router::<EngineState<S>>().with_state(state.clone());
168 app = app.merge(bw);
169 }
170
171 if auth_ui_enabled && !operator_ui_enabled {
172 let auth_url = state
173 .engine_config
174 .auth
175 .public_url
176 .as_deref()
177 .unwrap_or(&state.engine_config.server.public_url);
178 let auth_host = url::Url::parse(auth_url)
179 .ok()
180 .and_then(|url| url.host_str().map(str::to_owned));
181 let root = Router::new()
182 .route("/", get(auth_origin_root))
183 .with_state(auth_host);
184 app = app.merge(root);
185 }
186
187 if !state.engine_config.server.allowed_hosts.is_empty() {
188 app = app.layer(axum::middleware::from_fn_with_state(
189 state,
190 allowed_host_middleware::<S>,
191 ));
192 }
193
194 app
195}
196
197async fn auth_origin_root(
198 axum::extract::State(auth_host): axum::extract::State<Option<String>>,
199 headers: HeaderMap,
200) -> Response {
201 let request_host = request_host(&headers);
202 if auth_host
203 .as_deref()
204 .zip(request_host.as_deref())
205 .is_some_and(|(expected, actual)| expected.eq_ignore_ascii_case(actual))
206 {
207 return Redirect::temporary("/auth/landing").into_response();
208 }
209 StatusCode::NOT_FOUND.into_response()
210}
211
212async fn allowed_host_middleware<S: WorkflowStore + Clone + 'static>(
213 axum::extract::State(state): axum::extract::State<EngineState<S>>,
214 request: axum::extract::Request,
215 next: axum::middleware::Next,
216) -> Response {
217 if request.uri().path() == "/api/v1/engine/core/health"
218 || host_is_allowed(request.headers(), &state.engine_config.server.allowed_hosts)
219 {
220 return next.run(request).await;
221 }
222 StatusCode::MISDIRECTED_REQUEST.into_response()
223}
224
225fn host_is_allowed(headers: &HeaderMap, allowed_hosts: &[String]) -> bool {
226 if allowed_hosts.is_empty() {
227 return true;
228 }
229 let Some(host) = request_host(headers) else {
230 return false;
231 };
232 allowed_hosts
233 .iter()
234 .any(|allowed| allowed.eq_ignore_ascii_case(&host))
235}
236
237fn request_host(headers: &HeaderMap) -> Option<String> {
238 let value = headers.get(header::HOST)?.to_str().ok()?;
239 value
240 .parse::<axum::http::uri::Authority>()
241 .ok()
242 .map(|authority| authority.host().to_owned())
243}
244
245async fn admin_bearer_middleware<S: WorkflowStore + Clone + 'static>(
258 axum::extract::State(state): axum::extract::State<EngineState<S>>,
259 request: axum::extract::Request,
260 next: axum::middleware::Next,
261) -> axum::response::Response {
262 let path = request.uri().path();
263 if (path.starts_with("/share/") && path != "/share/revoke")
264 || (path.starts_with("/api/v1/vault/share/") && path != "/api/v1/vault/share/revoke")
265 {
266 return next.run(request).await;
267 }
268 let keys = crate::state::AdminApiKeys(Arc::clone(&state.admin_api_keys));
269 let outcome = match state.auth.as_ref() {
273 Some(auth) => assay_auth::gate::require_admin_or_jwt(request.headers(), auth, &keys)
274 .await
275 .map(|_| ()),
276 None => assay_auth::gate::require_admin_bearer(request.headers(), &keys),
277 };
278 if let Err(r) = outcome {
279 return *r;
280 }
281 next.run(request).await
282}
283
284pub async fn serve<S: WorkflowStore + Clone + 'static>(
290 bind_addr: &str,
291 state: EngineState<S>,
292) -> anyhow::Result<()> {
293 let app = build_app(state);
294 bind_and_serve(bind_addr, app).await
295}
296
297pub async fn bind_and_serve(bind_addr: &str, app: axum::Router) -> anyhow::Result<()> {
305 let listener = tokio::net::TcpListener::bind(bind_addr)
306 .await
307 .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
308 let actual = listener.local_addr()?;
309 info!(target: "assay-engine", %actual, "listening");
310 axum::serve(listener, app).await?;
311 Ok(())
312}
313
314pub fn build_workflow_ctx<S: WorkflowStore + 'static>(store: S) -> Arc<WorkflowCtx<S>> {
319 let ctx = WorkflowCtx::start(Arc::new(store)).with_binary_version(env!("CARGO_PKG_VERSION"));
320 Arc::new(ctx)
321}
322
323pub fn build_workflow_ctx_with_bus<S: WorkflowStore + 'static>(
327 store: S,
328 bus: Arc<dyn EngineEventBus>,
329) -> Arc<WorkflowCtx<S>> {
330 let ctx = WorkflowCtx::start(Arc::new(store))
331 .with_binary_version(env!("CARGO_PKG_VERSION"))
332 .with_event_bus(WorkflowEventBus::new(bus));
333 Arc::new(ctx)
334}
335
336#[cfg(test)]
337mod host_boundary_tests {
338 use axum::extract::State;
339 use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
340
341 use super::{auth_origin_root, host_is_allowed};
342
343 #[tokio::test]
344 async fn auth_origin_root_enters_public_auth_while_engine_root_stays_hidden() {
345 let mut auth_headers = HeaderMap::new();
346 auth_headers.insert(header::HOST, HeaderValue::from_static("auth.assay.rs"));
347 let auth = auth_origin_root(State(Some("auth.assay.rs".to_string())), auth_headers).await;
348 assert_eq!(auth.status(), StatusCode::TEMPORARY_REDIRECT);
349 assert_eq!(auth.headers()[header::LOCATION], "/auth/landing");
350
351 let mut engine_headers = HeaderMap::new();
352 engine_headers.insert(header::HOST, HeaderValue::from_static("engine.assay.rs"));
353 let engine =
354 auth_origin_root(State(Some("auth.assay.rs".to_string())), engine_headers).await;
355 assert_eq!(engine.status(), StatusCode::NOT_FOUND);
356 }
357
358 #[test]
359 fn configured_hosts_are_case_insensitive_and_port_agnostic() {
360 let allowed = vec!["auth.assay.rs".to_string(), "engine.assay.rs".to_string()];
361 let mut headers = HeaderMap::new();
362 headers.insert(header::HOST, HeaderValue::from_static("AUTH.ASSAY.RS:443"));
363
364 assert!(host_is_allowed(&headers, &allowed));
365 }
366
367 #[test]
368 fn unknown_and_missing_hosts_are_rejected_when_the_allowlist_is_configured() {
369 let allowed = vec!["auth.assay.rs".to_string(), "engine.assay.rs".to_string()];
370 let mut headers = HeaderMap::new();
371 headers.insert(header::HOST, HeaderValue::from_static("assay-auth.fly.dev"));
372
373 assert!(!host_is_allowed(&headers, &allowed));
374 assert!(!host_is_allowed(&HeaderMap::new(), &allowed));
375 }
376
377 #[test]
378 fn an_empty_allowlist_preserves_embedded_and_local_callers() {
379 assert!(host_is_allowed(&HeaderMap::new(), &[]));
380 }
381}