assay-engine 0.5.15

Standalone workflow + auth + dashboard HTTP server on PostgreSQL 18 + SQLite. Embeddable as a library, or run as a binary.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! HTTP server wiring — composes the workflow API + dashboard + auth
//! routers into one axum `Router`. URL surface:
//!
//! - `/auth/*`                   OIDC spec (discovery, authorize, token, …)
//! - `/api/v1/engine/core/*`     engine-core admin (admin-bearer-gated)
//! - `/api/v1/engine/workflow/*` workflow API (admin-bearer-gated)
//! - `/api/v1/engine/auth/*`     engine-internal auth + admin
//! - `/api/v1/vault/*`           vault module (admin-bearer-gated)
//! - `/v1/*`                     Vault/OpenBao KV2 read facade (opt-in)
//! - `/healthz`                  redirect to `/api/v1/engine/core/health`
//!
//! Per the decoupled-modules architecture: each module accepts ONLY
//! an admin bearer token at its HTTP boundary. Per-user authentication
//! and policy decisions live upstream of the engine — typically in a
//! dashboard / BFF / API gateway that validates the user session, asks
//! zanzibar if they're allowed, and then forwards the call to the
//! engine using its own admin bearer. The engine itself does not
//! resolve sessions or check zanzibar at request time.
//!
//! Share-redeem (`GET /api/v1/vault/share/{token}`) is the one route
//! that bypasses admin bearer — the biscuit token in the URL is its
//! own auth, verified inside the handler.

use axum::Router;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use std::sync::Arc;
use tracing::info;

use assay_domain::events::EngineEventBus;
use assay_workflow::events::WorkflowEventBus;
use assay_workflow::{WorkflowCtx, WorkflowStore};

use crate::state::EngineState;

/// Compose the full `axum::Router` for the engine.
///
/// The workflow crate returns a `Router` that already embeds its state,
/// and the dashboard crate returns a `Router<Arc<DashboardCtx>>` that we
/// `.with_state()` here. Both are merged into a single stateless `Router`
/// ready for `axum::serve`. When the `auth` feature is on AND the
/// engine boot constructed an `AuthCtx`, the OIDC spec router (mounted
/// at `/auth/`) and the engine-internal auth router (mounted under
/// `/api/v1/engine/auth/`) join the composition.
pub fn build_app<S: WorkflowStore + Clone + 'static>(state: EngineState<S>) -> Router {
    let operator_ui_enabled = state.engine_config.dashboard.operator_enabled();
    let auth_ui_enabled = state.engine_config.dashboard.auth_ui_enabled();
    // Workflow router takes a non-optional gate closure (typechecked).
    // We supply admin_bearer_middleware as the gate; the workflow
    // crate applies it to only the authed portion of the router so
    // /health, /version, /openapi.json, /docs stay public for probes.
    let state_for_workflow = state.clone();
    let workflow_router = assay_workflow::api::router(Arc::clone(&state.workflow), |r| {
        r.layer(axum::middleware::from_fn_with_state(
            state_for_workflow,
            admin_bearer_middleware::<S>,
        ))
    });

    // `/healthz` is kept as a 1-line redirect to the new engine-core
    // health endpoint for backward-compatible k8s probes. The real
    // health response is served by the engine-core router under
    // `/api/v1/engine/core/health` (see `engine_api.rs`).
    let healthz = Router::new().route(
        "/healthz",
        get(|| async { Redirect::permanent("/api/v1/engine/core/health") }),
    );

    // Engine-core admin API. The handlers require an admin api-key
    // bearer; when `admin_api_keys` is empty every admin route returns
    // 401, so mounting unconditionally is safe for no-auth builds.
    let engine_api_router = crate::engine_api::router::<S>().with_state(state.clone());

    let mut app = workflow_router.merge(healthz).merge(engine_api_router);

    // Built-in operator SPAs. The engine ships its own browser UI —
    // auth console, vault console, workflow dashboard, engine console —
    // so a stand-alone deployment (no sysops in front) is usable from
    // a browser. SPAs prompt for an admin api-key and store it in
    // localStorage, then call the engine API with that bearer.
    // Deployments fronting the engine with sysops/gondor (or any
    // other dashboard) toggle this off at runtime with
    // `[dashboard] enabled = false` in engine.toml.
    if operator_ui_enabled {
        let dashboard_router =
            assay_dashboard::workflow_router().with_state(Arc::clone(&state.dashboard));
        let engine_console_router = assay_dashboard::engine_router();
        app = app.merge(dashboard_router).merge(engine_console_router);
    }

    // Mount the auth routers when AuthCtx is present. We bind state to
    // each router *before* nesting so the merged tree remains
    // `Router<()>` (every other sub-router has its state baked in
    // similarly). This avoids the axum requirement that all merged
    // routers share a common state parameter.
    //
    // The routers are generic over a parent state from which both
    // `AuthCtx` and `AdminApiKeys` are extractable via `FromRef`;
    // `EngineState<S>` implements both impls (see `state.rs`), so the
    // engine threads its full state in once and the auth handlers
    // pluck what they need.
    if state.auth.is_some() {
        // OIDC spec endpoints — mounted at `/auth/...`. Discovery doc,
        // JWKS, authorize/token/userinfo/revoke/introspect/logout,
        // federation upstream callbacks. Stable surface that downstream
        // OIDC clients depend on.
        let spec_router =
            assay_auth::oidc_spec_router::<EngineState<S>>().with_state(state.clone());
        app = app.nest("/auth", spec_router);

        // Engine-internal auth — login, logout (DELETE), whoami,
        // passkey ceremonies, admin (users/sessions/biscuit/jwks/
        // zanzibar/audit + OIDC clients/upstream CRUD). Mounted under
        // `/api/v1/engine/auth/...` so the operator-facing surface
        // sits beside the engine-core + workflow APIs.
        let engine_auth_router =
            assay_auth::engine_auth_router::<EngineState<S>>().with_state(state.clone());
        app = app.nest("/api/v1/engine/auth", engine_auth_router);

        // Browser sign-in/recovery and the operator auth console are
        // independently gated so a public issuer does not need to expose
        // administrative HTML.
        if auth_ui_enabled {
            app = app.merge(assay_dashboard::auth_public_router());
        }
        if operator_ui_enabled {
            app = app.merge(assay_dashboard::auth_console_router());
        }
    }

    // Vault module — plan 17 / v0.3.0. Mounted under /api/v1/vault when
    // both the Cargo feature is on AND a VaultCtx was composed at boot
    // (i.e. engine.modules.vault.enabled was TRUE). Phase 1 routes are
    // admin-key-gated; Phase 3+ adds biscuit-share and Phase 7 the
    // BW-compat shim's per-user session auth.
    #[cfg(feature = "vault")]
    if state.vault.is_some() {
        // Vault router requires a non-optional gate closure
        // (typechecked). We supply admin_bearer_middleware, which has
        // the share-redeem path bypass built in so /share/{token}
        // stays public (biscuit verifies in the handler).
        let state_for_vault = state.clone();
        let vault = assay_vault::router::vault_router::<EngineState<S>, _>(|r| {
            r.layer(axum::middleware::from_fn_with_state(
                state_for_vault,
                admin_bearer_middleware::<S>,
            ))
        })
        .with_state(state.clone());
        app = app.nest("/api/v1/vault", vault);

        // Vault console SPA at /vault, /vault/console, /vault/console/*.
        // Runtime-gated on the dashboard flag, same as the other SPAs.
        if operator_ui_enabled {
            app = app.merge(assay_dashboard::vault_router());
        }
    }

    #[cfg(all(feature = "vault", feature = "vault-hashicorp-compat"))]
    if state.vault.is_some() && state.engine_config.vault.hashicorp_compat.enabled {
        app = app.merge(hashicorp_compat_router(&state));
    }

    // BW-compat shim (Phase 7). Stock BW mobile / browser / CLI
    // clients hardcode /identity/* and /api/* — mount the compat
    // router at root so those clients work without a reverse-proxy
    // rewrite. Only reachable when both vault + bitwarden-compat
    // features are on AND VaultCtx + AuthCtx are composed.
    #[cfg(all(feature = "vault", feature = "vault-bitwarden-compat"))]
    if state.vault.is_some() && state.auth.is_some() {
        let bw =
            assay_vault::bitwarden_compat::router::<EngineState<S>>().with_state(state.clone());
        app = app.merge(bw);
    }

    if auth_ui_enabled && !operator_ui_enabled {
        let auth_url = state
            .engine_config
            .auth
            .public_url
            .as_deref()
            .unwrap_or(&state.engine_config.server.public_url);
        let auth_host = url::Url::parse(auth_url)
            .ok()
            .and_then(|url| url.host_str().map(str::to_owned));
        let root = Router::new()
            .route("/", get(auth_origin_root))
            .with_state(auth_host);
        app = app.merge(root);
    }

    if !state.engine_config.server.allowed_hosts.is_empty() {
        app = app.layer(axum::middleware::from_fn_with_state(
            state,
            allowed_host_middleware::<S>,
        ));
    }

    app
}

/// Vault / OpenBao KV2 read facade, mounted at the router root because Vault
/// clients (ESO, ansible, curl) hardcode `/v1/…` and cannot be told to use a
/// prefix. It carries the same admin-bearer gate every other module surface
/// does, translating `X-Vault-Token` into that bearer on the way in.
#[cfg(all(feature = "vault", feature = "vault-hashicorp-compat"))]
fn hashicorp_compat_router<S: WorkflowStore + Clone + 'static>(state: &EngineState<S>) -> Router {
    let compat = &state.engine_config.vault.hashicorp_compat;
    let mount = assay_vault::hashicorp_compat::Mount::new(&compat.mount);
    let state_for_gate = state.clone();
    assay_vault::hashicorp_compat::router::<EngineState<S>, _>(mount, |r| {
        r.layer(axum::middleware::from_fn_with_state(
            state_for_gate,
            admin_bearer_middleware::<S>,
        ))
    })
    .with_state(state.clone())
}

async fn auth_origin_root(
    axum::extract::State(auth_host): axum::extract::State<Option<String>>,
    headers: HeaderMap,
) -> Response {
    let request_host = request_host(&headers);
    if auth_host
        .as_deref()
        .zip(request_host.as_deref())
        .is_some_and(|(expected, actual)| expected.eq_ignore_ascii_case(actual))
    {
        return Redirect::temporary("/auth/landing").into_response();
    }
    StatusCode::NOT_FOUND.into_response()
}

async fn allowed_host_middleware<S: WorkflowStore + Clone + 'static>(
    axum::extract::State(state): axum::extract::State<EngineState<S>>,
    request: axum::extract::Request,
    next: axum::middleware::Next,
) -> Response {
    if request.uri().path() == "/api/v1/engine/core/health"
        || host_is_allowed(request.headers(), &state.engine_config.server.allowed_hosts)
    {
        return next.run(request).await;
    }
    StatusCode::MISDIRECTED_REQUEST.into_response()
}

fn host_is_allowed(headers: &HeaderMap, allowed_hosts: &[String]) -> bool {
    if allowed_hosts.is_empty() {
        return true;
    }
    let Some(host) = request_host(headers) else {
        return false;
    };
    allowed_hosts
        .iter()
        .any(|allowed| allowed.eq_ignore_ascii_case(&host))
}

fn request_host(headers: &HeaderMap) -> Option<String> {
    let value = headers.get(header::HOST)?.to_str().ok()?;
    value
        .parse::<axum::http::uri::Authority>()
        .ok()
        .map(|authority| authority.host().to_owned())
}

/// Resource-server middleware applied to every engine module router.
/// Accepts EITHER the operator admin api-key (service-to-service /
/// break-glass) OR a JWT from a configured trusted issuer (per-user
/// resource-server pattern). No session, no zanzibar — policy lives
/// upstream.
///
/// The one bypass: vault share-redeem (`GET /share/{token}` —
/// path-relative because this middleware runs INSIDE the nested
/// `/api/v1/vault` router, after axum has stripped the prefix).
/// `/share/revoke` is excluded — it's an admin operation that mints
/// or revokes tokens. The biscuit token in the share-redeem URL is
/// its own authentication.
async fn admin_bearer_middleware<S: WorkflowStore + Clone + 'static>(
    axum::extract::State(state): axum::extract::State<EngineState<S>>,
    request: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    let path = request.uri().path();
    if (path.starts_with("/share/") && path != "/share/revoke")
        || (path.starts_with("/api/v1/vault/share/") && path != "/api/v1/vault/share/revoke")
    {
        return next.run(request).await;
    }
    let keys = crate::state::AdminApiKeys(Arc::clone(&state.admin_api_keys));
    // If auth is configured, accept admin bearer OR trusted JWT.
    // If auth is not configured at all (no AuthCtx), only the admin
    // bearer path is available — fall back to the strict check.
    let outcome = match state.auth.as_ref() {
        Some(auth) => assay_auth::gate::require_admin_or_jwt(request.headers(), auth, &keys)
            .await
            .map(|_| ()),
        None => assay_auth::gate::require_admin_bearer(request.headers(), &keys),
    };
    if let Err(r) = outcome {
        return *r;
    }
    next.run(request).await
}

/// Bind a TCP listener on `bind_addr` and serve the composed app.
///
/// Convenience wrapper that composes [`EngineState`] into an
/// [`axum::Router`] via [`build_app`] and hands off to
/// [`bind_and_serve`]. Used by the standalone `assay-engine` binary.
pub async fn serve<S: WorkflowStore + Clone + 'static>(
    bind_addr: &str,
    state: EngineState<S>,
) -> anyhow::Result<()> {
    let app = build_app(state);
    bind_and_serve(bind_addr, app).await
}

/// Bind a TCP listener on `bind_addr` and serve a pre-built
/// [`axum::Router`].
///
/// Used by [`crate::run`] (after `embedded::build` returns the
/// composed router) and by downstream embedders who want to add
/// their own middleware / merge with their own router before
/// serving.
pub async fn bind_and_serve(bind_addr: &str, app: axum::Router) -> anyhow::Result<()> {
    let listener = tokio::net::TcpListener::bind(bind_addr)
        .await
        .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
    let actual = listener.local_addr()?;
    info!(target: "assay-engine", %actual, "listening");
    axum::serve(listener, app).await?;
    Ok(())
}

/// Start a `WorkflowCtx` around the given store. Authentication +
/// authorization are no longer the workflow's concern — the engine
/// wraps the workflow router with a gate middleware ([`build_app`])
/// that handles all of that.
pub fn build_workflow_ctx<S: WorkflowStore + 'static>(store: S) -> Arc<WorkflowCtx<S>> {
    let ctx = WorkflowCtx::start(Arc::new(store)).with_binary_version(env!("CARGO_PKG_VERSION"));
    Arc::new(ctx)
}

/// Like [`build_workflow_ctx`] but also wires the engine-wide event
/// bus into the workflow context so SSE + the dispatch-wakeup loop see
/// state transitions.
pub fn build_workflow_ctx_with_bus<S: WorkflowStore + 'static>(
    store: S,
    bus: Arc<dyn EngineEventBus>,
) -> Arc<WorkflowCtx<S>> {
    let ctx = WorkflowCtx::start(Arc::new(store))
        .with_binary_version(env!("CARGO_PKG_VERSION"))
        .with_event_bus(WorkflowEventBus::new(bus));
    Arc::new(ctx)
}

#[cfg(test)]
mod host_boundary_tests {
    use axum::extract::State;
    use axum::http::{HeaderMap, HeaderValue, StatusCode, header};

    use super::{auth_origin_root, host_is_allowed};

    #[tokio::test]
    async fn auth_origin_root_enters_public_auth_while_engine_root_stays_hidden() {
        let mut auth_headers = HeaderMap::new();
        auth_headers.insert(header::HOST, HeaderValue::from_static("auth.assay.rs"));
        let auth = auth_origin_root(State(Some("auth.assay.rs".to_string())), auth_headers).await;
        assert_eq!(auth.status(), StatusCode::TEMPORARY_REDIRECT);
        assert_eq!(auth.headers()[header::LOCATION], "/auth/landing");

        let mut engine_headers = HeaderMap::new();
        engine_headers.insert(header::HOST, HeaderValue::from_static("engine.assay.rs"));
        let engine =
            auth_origin_root(State(Some("auth.assay.rs".to_string())), engine_headers).await;
        assert_eq!(engine.status(), StatusCode::NOT_FOUND);
    }

    #[test]
    fn configured_hosts_are_case_insensitive_and_port_agnostic() {
        let allowed = vec!["auth.assay.rs".to_string(), "engine.assay.rs".to_string()];
        let mut headers = HeaderMap::new();
        headers.insert(header::HOST, HeaderValue::from_static("AUTH.ASSAY.RS:443"));

        assert!(host_is_allowed(&headers, &allowed));
    }

    #[test]
    fn unknown_and_missing_hosts_are_rejected_when_the_allowlist_is_configured() {
        let allowed = vec!["auth.assay.rs".to_string(), "engine.assay.rs".to_string()];
        let mut headers = HeaderMap::new();
        headers.insert(header::HOST, HeaderValue::from_static("assay-auth.fly.dev"));

        assert!(!host_is_allowed(&headers, &allowed));
        assert!(!host_is_allowed(&HeaderMap::new(), &allowed));
    }

    #[test]
    fn an_empty_allowlist_preserves_embedded_and_local_callers() {
        assert!(host_is_allowed(&HeaderMap::new(), &[]));
    }
}