Skip to main content

assay_engine/
lib.rs

1//! `assay-engine` — one static binary that replaces a Temporal +
2//! Kratos + Hydra + Keto stack.
3//!
4//! `v0.2.0` is the umbrella release that turns the engine into a full
5//! IdP + workflow runtime: the previously-empty [`auth`] feature now
6//! pulls [`assay_auth`] in, mounting OIDC client + provider, passkey,
7//! Argon2 password, JWT + JWKS rotation, biscuit capability tokens,
8//! Zanzibar ReBAC, session + admin endpoints under `/auth`. The
9//! dashboard panes that consume those routes (Users, Sessions, OIDC
10//! clients, Upstream providers, Zanzibar, JWKS, Biscuit, Audit) light up
11//! when the auth module is enabled in `engine.modules`.
12//!
13//! Composition is via [`axum::extract::FromRef`] over [`EngineState<S>`]
14//! — workflow / auth / dashboard each contribute their own `Ctx` and
15//! the parent state derives every sub-state extractor automatically. A
16//! no-auth build (`--no-default-features --features
17//! "backend-postgres,backend-sqlite"`) compiles identically to the
18//! pre-v0.2.0 engine; an auth build composes the auth ctx if and only if
19//! `engine.modules` shows `auth` enabled at boot.
20//!
21//! ## Module enablement model
22//!
23//! Three layers compose:
24//!
25//! 1. **Compile features (Cargo)** — decide whether the module's code
26//!    is *linked* into the binary. `assay-engine`'s default compiles
27//!    workflow + dashboard; opt into `auth` for the IdP.
28//! 2. **`engine.modules` row (DB)** — decides whether the module is
29//!    *active* at runtime. `name`, `enabled`, `version`, `config`. The
30//!    boot path runs the module's migrations + mounts its routes + lets
31//!    the dashboard render its panes only when `enabled = TRUE`.
32//! 3. **`engine.toml` config** — decides how the active module is
33//!    *configured* (issuer URL, session TTL, OIDC provider toggle,
34//!    admin api-keys, …).
35//!
36//! See plan 12 § Architecture principle 1 (composition) and § principle
37//! 8 (runtime/engine split). Migration notes for v0.1.x → v0.2.0 live
38//! in `docs/migration-to-0.2.0.md`.
39
40use std::sync::Arc;
41
42pub mod config;
43pub mod embedded;
44pub mod engine_api;
45pub mod init;
46pub mod server;
47pub mod state;
48
49pub use assay_auth as auth;
50pub use assay_dashboard as dashboard;
51pub use assay_domain as core;
52pub use assay_workflow as workflow;
53
54pub use config::{
55    AuthConfig, AuthOidcProviderConfig, AuthPasskeyConfig, AuthSessionConfig, BackendConfig,
56    DashboardConfig, EngineConfig, ServerConfig,
57};
58pub use state::{AdminApiKeys, EngineState};
59
60/// Top-level entrypoint for the standalone `assay-engine` binary.
61/// Picks the backend from config, composes engine via
62/// [`embedded::build`], and serves forever on `cfg.server.bind_addr`.
63///
64/// For embedded use (composing engine into a parent binary's
65/// router), call [`embedded::build`] directly.
66pub async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
67    let bind_addr = cfg.server.bind_addr.clone();
68    let engine = embedded::build(cfg).await?;
69    server::bind_and_serve(&bind_addr, engine.router).await
70}
71
72/// Build the vault context iff the runtime `engine.modules.vault.enabled`
73/// row is TRUE. Loads the master KEK from `vault.kek_metadata` (or seeds
74/// a fresh one on first boot) and composes the per-feature stores
75/// against the same pool the rest of the engine uses.
76#[cfg(all(feature = "vault", feature = "backend-postgres"))]
77async fn build_vault_ctx_pg(
78    modules: &[String],
79    pool: &sqlx::PgPool,
80) -> anyhow::Result<Option<assay_vault::VaultCtx>> {
81    if !modules.iter().any(|m| m == "vault") {
82        return Ok(None);
83    }
84    let kek = assay_vault::crypto::kek_store::load_or_init_postgres(pool)
85        .await
86        .map_err(|e| anyhow::anyhow!("vault KEK bootstrap (pg): {e}"))?;
87    // The `vault` umbrella feature on assay-vault implies vault-kv +
88    // vault-transit, so the with_* methods are unconditionally
89    // available here.
90    let mut ctx = assay_vault::VaultCtx::new()
91        .with_kek(kek)
92        .with_kv(assay_vault::store::postgres::PgKvStore::new(pool.clone()))
93        .with_transit(assay_vault::store::postgres::PgTransitStore::new(
94            pool.clone(),
95        ));
96    #[cfg(feature = "vault-sealing-shamir")]
97    {
98        ctx = ctx.with_seal_store(assay_vault::store::postgres::PgSealStore::new(pool.clone()));
99    }
100    #[cfg(feature = "vault-collections")]
101    {
102        ctx = ctx
103            .with_personal_vaults(assay_vault::store::postgres::PgPersonalVaultStore::new(
104                pool.clone(),
105            ))
106            .with_collections(assay_vault::store::postgres::PgCollectionStore::new(
107                pool.clone(),
108            ))
109            .with_items(assay_vault::store::postgres::PgItemStore::new(pool.clone()))
110            .with_folders(assay_vault::store::postgres::PgFolderStore::new(
111                pool.clone(),
112            ));
113    }
114    #[cfg(feature = "vault-share")]
115    {
116        let kp = assay_vault::store::postgres::load_or_init_biscuit_root_postgres(pool)
117            .await
118            .map_err(|e| anyhow::anyhow!("vault biscuit root bootstrap (pg): {e}"))?;
119        let revs = std::sync::Arc::new(assay_vault::store::postgres::PgRevocationStore::new(
120            pool.clone(),
121        ));
122        let svc = assay_vault::share::ShareService::new(kp, revs);
123        ctx = ctx.with_share(svc);
124    }
125    #[cfg(feature = "vault-dynamic-postgres")]
126    {
127        let leases = std::sync::Arc::new(assay_vault::store::postgres::PgLeaseStore::new(
128            pool.clone(),
129        ));
130        let registry = assay_vault::dynamic::DynamicCredsRegistry::new();
131        // Phase 5 default-config: registry is empty until an operator
132        // configures providers via /dynamic/* admin routes (or in
133        // future, engine.toml). The dispatcher returns NotFound for
134        // unknown providers, which surfaces as 404 to the caller.
135        let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
136        ctx = ctx.with_dynamic(svc);
137    }
138    Ok(Some(ctx))
139}
140
141/// SQLite mirror of [`build_vault_ctx_pg`].
142#[cfg(all(feature = "vault", feature = "backend-sqlite"))]
143async fn build_vault_ctx_sqlite(
144    modules: &[String],
145    pool: &sqlx::SqlitePool,
146) -> anyhow::Result<Option<assay_vault::VaultCtx>> {
147    if !modules.iter().any(|m| m == "vault") {
148        return Ok(None);
149    }
150    let kek = assay_vault::crypto::kek_store::load_or_init_sqlite(pool)
151        .await
152        .map_err(|e| anyhow::anyhow!("vault KEK bootstrap (sqlite): {e}"))?;
153    let mut ctx = assay_vault::VaultCtx::new()
154        .with_kek(kek)
155        .with_kv(assay_vault::store::sqlite::SqliteKvStore::new(pool.clone()))
156        .with_transit(assay_vault::store::sqlite::SqliteTransitStore::new(
157            pool.clone(),
158        ));
159    #[cfg(feature = "vault-sealing-shamir")]
160    {
161        ctx = ctx.with_seal_store(assay_vault::store::sqlite::SqliteSealStore::new(
162            pool.clone(),
163        ));
164    }
165    #[cfg(feature = "vault-collections")]
166    {
167        ctx = ctx
168            .with_personal_vaults(assay_vault::store::sqlite::SqlitePersonalVaultStore::new(
169                pool.clone(),
170            ))
171            .with_collections(assay_vault::store::sqlite::SqliteCollectionStore::new(
172                pool.clone(),
173            ))
174            .with_items(assay_vault::store::sqlite::SqliteItemStore::new(
175                pool.clone(),
176            ))
177            .with_folders(assay_vault::store::sqlite::SqliteFolderStore::new(
178                pool.clone(),
179            ));
180    }
181    #[cfg(feature = "vault-share")]
182    {
183        let kp = assay_vault::store::sqlite::load_or_init_biscuit_root_sqlite(pool)
184            .await
185            .map_err(|e| anyhow::anyhow!("vault biscuit root bootstrap (sqlite): {e}"))?;
186        let revs = std::sync::Arc::new(assay_vault::store::sqlite::SqliteRevocationStore::new(
187            pool.clone(),
188        ));
189        let svc = assay_vault::share::ShareService::new(kp, revs);
190        ctx = ctx.with_share(svc);
191    }
192    #[cfg(feature = "vault-dynamic-postgres")]
193    {
194        let leases = std::sync::Arc::new(assay_vault::store::sqlite::SqliteLeaseStore::new(
195            pool.clone(),
196        ));
197        let registry = assay_vault::dynamic::DynamicCredsRegistry::new();
198        let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
199        ctx = ctx.with_dynamic(svc);
200    }
201    Ok(Some(ctx))
202}
203
204#[cfg(feature = "backend-postgres")]
205async fn build_auth_ctx_pg(
206    cfg: &EngineConfig,
207    pool: &sqlx::PgPool,
208) -> anyhow::Result<assay_auth::AuthCtx> {
209    use assay_auth::store::{PostgresSessionStore, PostgresUserStore};
210    let users = PostgresUserStore::new(pool.clone()).into_dyn();
211    let sessions = PostgresSessionStore::new(pool.clone()).into_dyn();
212    let mut ctx = assay_auth::AuthCtx::new(users.clone(), sessions);
213
214    let biscuit = assay_auth::biscuit::load_or_init_postgres(pool)
215        .await
216        .map_err(|e| anyhow::anyhow!("biscuit root key (pg): {e}"))?;
217    ctx = ctx.with_biscuit(biscuit);
218
219    #[cfg(feature = "auth-jwt")]
220    {
221        let issuer = effective_issuer(cfg);
222        let audience = if cfg.auth.audience.is_empty() {
223            vec![issuer.clone()]
224        } else {
225            cfg.auth.audience.clone()
226        };
227        let jwt = assay_auth::jwt::JwtConfig::new(issuer.clone(), audience);
228        if let Err(e) = jwt.load_from_postgres(pool).await {
229            tracing::warn!(?e, "no JWKS rows yet; rotating to seed first key");
230            jwt.rotate_postgres(pool)
231                .await
232                .map_err(|e| anyhow::anyhow!("seed JWKS (pg): {e}"))?;
233        }
234        if jwt.active_kid().is_none() {
235            jwt.rotate_postgres(pool)
236                .await
237                .map_err(|e| anyhow::anyhow!("seed JWKS (pg): {e}"))?;
238        }
239        ctx = ctx.with_jwt(jwt);
240
241        ctx = ctx.with_external_issuers(discover_external_issuers(cfg).await?);
242    }
243
244    #[cfg(feature = "auth-oidc")]
245    {
246        ctx = ctx.with_oidc(assay_auth::oidc::OidcRegistry::new());
247    }
248
249    #[cfg(feature = "auth-passkey")]
250    if let Some(passkey_mgr) = build_passkey_manager(cfg, users.clone()) {
251        ctx = ctx.with_passkeys(passkey_mgr);
252    }
253
254    #[cfg(feature = "auth-zanzibar")]
255    {
256        let zanzibar: Arc<dyn assay_auth::zanzibar::ZanzibarStore> = Arc::new(
257            assay_auth::zanzibar::PostgresZanzibarStore::new(pool.clone()),
258        );
259        ctx = ctx.with_zanzibar(zanzibar);
260    }
261
262    #[cfg(feature = "auth-oidc-provider")]
263    if cfg.auth.oidc_provider.enabled {
264        let issuer = oidc_issuer(cfg);
265        let public_url = oidc_public_url(cfg)?;
266        let provider = assay_auth::oidc_provider::OidcProviderConfig::new(
267            issuer,
268            public_url,
269            assay_auth::oidc_provider::PostgresOidcClientStore::new(pool.clone()).into_dyn(),
270            assay_auth::oidc_provider::PostgresOidcUpstreamStore::new(pool.clone()).into_dyn(),
271            assay_auth::oidc_provider::PostgresOidcCodeStore::new(pool.clone()).into_dyn(),
272            assay_auth::oidc_provider::PostgresOidcRefreshStore::new(pool.clone()).into_dyn(),
273            assay_auth::oidc_provider::PostgresOidcSessionStore::new(pool.clone()).into_dyn(),
274            assay_auth::oidc_provider::PostgresOidcConsentStore::new(pool.clone()).into_dyn(),
275            assay_auth::oidc_provider::PostgresOidcUpstreamStateStore::new(pool.clone()).into_dyn(),
276        )
277        .with_jwks_source(assay_auth::oidc_provider::JwksSource::Postgres(
278            pool.clone(),
279        ))
280        .with_auto_provision(cfg.auth.oidc_provider.auto_provision);
281        ctx = ctx.with_oidc_provider(provider);
282
283        if let (Some(registry), Some(provider)) = (&ctx.oidc, &ctx.oidc_provider) {
284            match provider.upstream.list().await {
285                Ok(rows) => {
286                    for row in rows {
287                        assay_auth::oidc_provider::sync_upstream_to_registry(
288                            registry,
289                            &row,
290                            &provider.public_url,
291                        )
292                        .await;
293                    }
294                }
295                Err(e) => {
296                    tracing::warn!("failed to list upstream providers at boot: {e}");
297                }
298            }
299        }
300    }
301
302    Ok(ctx)
303}
304
305#[cfg(feature = "backend-sqlite")]
306async fn build_auth_ctx_sqlite(
307    cfg: &EngineConfig,
308    pool: &sqlx::SqlitePool,
309) -> anyhow::Result<assay_auth::AuthCtx> {
310    use assay_auth::store::{SqliteSessionStore, SqliteUserStore};
311    let users = SqliteUserStore::new(pool.clone()).into_dyn();
312    let sessions = SqliteSessionStore::new(pool.clone()).into_dyn();
313    let mut ctx = assay_auth::AuthCtx::new(users.clone(), sessions);
314
315    let biscuit = assay_auth::biscuit::load_or_init_sqlite(pool)
316        .await
317        .map_err(|e| anyhow::anyhow!("biscuit root key (sqlite): {e}"))?;
318    ctx = ctx.with_biscuit(biscuit);
319
320    #[cfg(feature = "auth-jwt")]
321    {
322        let issuer = effective_issuer(cfg);
323        let audience = if cfg.auth.audience.is_empty() {
324            vec![issuer.clone()]
325        } else {
326            cfg.auth.audience.clone()
327        };
328        let jwt = assay_auth::jwt::JwtConfig::new(issuer.clone(), audience);
329        if let Err(e) = jwt.load_from_sqlite(pool).await {
330            tracing::warn!(?e, "no JWKS rows yet; rotating to seed first key");
331            jwt.rotate_sqlite(pool)
332                .await
333                .map_err(|e| anyhow::anyhow!("seed JWKS (sqlite): {e}"))?;
334        }
335        if jwt.active_kid().is_none() {
336            jwt.rotate_sqlite(pool)
337                .await
338                .map_err(|e| anyhow::anyhow!("seed JWKS (sqlite): {e}"))?;
339        }
340        ctx = ctx.with_jwt(jwt);
341
342        ctx = ctx.with_external_issuers(discover_external_issuers(cfg).await?);
343    }
344
345    #[cfg(feature = "auth-oidc")]
346    {
347        ctx = ctx.with_oidc(assay_auth::oidc::OidcRegistry::new());
348    }
349
350    #[cfg(feature = "auth-passkey")]
351    if let Some(passkey_mgr) = build_passkey_manager(cfg, users.clone()) {
352        ctx = ctx.with_passkeys(passkey_mgr);
353    }
354
355    #[cfg(feature = "auth-zanzibar")]
356    {
357        let zanzibar: Arc<dyn assay_auth::zanzibar::ZanzibarStore> =
358            Arc::new(assay_auth::zanzibar::SqliteZanzibarStore::new(pool.clone()));
359        ctx = ctx.with_zanzibar(zanzibar);
360    }
361
362    #[cfg(feature = "auth-oidc-provider")]
363    if cfg.auth.oidc_provider.enabled {
364        let issuer = oidc_issuer(cfg);
365        let public_url = oidc_public_url(cfg)?;
366        let provider = assay_auth::oidc_provider::OidcProviderConfig::new(
367            issuer,
368            public_url,
369            assay_auth::oidc_provider::SqliteOidcClientStore::new(pool.clone()).into_dyn(),
370            assay_auth::oidc_provider::SqliteOidcUpstreamStore::new(pool.clone()).into_dyn(),
371            assay_auth::oidc_provider::SqliteOidcCodeStore::new(pool.clone()).into_dyn(),
372            assay_auth::oidc_provider::SqliteOidcRefreshStore::new(pool.clone()).into_dyn(),
373            assay_auth::oidc_provider::SqliteOidcSessionStore::new(pool.clone()).into_dyn(),
374            assay_auth::oidc_provider::SqliteOidcConsentStore::new(pool.clone()).into_dyn(),
375            assay_auth::oidc_provider::SqliteOidcUpstreamStateStore::new(pool.clone()).into_dyn(),
376        )
377        .with_jwks_source(assay_auth::oidc_provider::JwksSource::Sqlite(pool.clone()))
378        .with_auto_provision(cfg.auth.oidc_provider.auto_provision);
379        ctx = ctx.with_oidc_provider(provider);
380
381        if let (Some(registry), Some(provider)) = (&ctx.oidc, &ctx.oidc_provider) {
382            match provider.upstream.list().await {
383                Ok(rows) => {
384                    for row in rows {
385                        assay_auth::oidc_provider::sync_upstream_to_registry(
386                            registry,
387                            &row,
388                            &provider.public_url,
389                        )
390                        .await;
391                    }
392                }
393                Err(e) => {
394                    tracing::warn!("failed to list upstream providers at boot: {e}");
395                }
396            }
397        }
398    }
399
400    Ok(ctx)
401}
402
403/// Discover each configured external OIDC issuer once at boot and
404/// hand back ready-to-use verifiers. Each verifier owns a background
405/// task that refreshes its JWKS on the configured interval.
406///
407/// Errors here are fatal — if Hydra (or whichever IdP) is unreachable
408/// at boot the engine shouldn't pretend it can validate tokens. The
409/// alternative (silently degrading to "no external issuer trusted")
410/// would surface as 401s and look like a session bug.
411#[cfg(feature = "auth-jwt")]
412async fn discover_external_issuers(
413    cfg: &EngineConfig,
414) -> anyhow::Result<Vec<assay_auth::external_jwt::ExternalJwtIssuer>> {
415    let entries = cfg.auth.external_issuers();
416    let mut out = Vec::with_capacity(entries.len());
417    for entry in entries {
418        let verifier = assay_auth::external_jwt::ExternalJwtIssuer::discover(
419            entry.issuer_url.clone(),
420            entry.audience.clone(),
421            entry.jwks_refresh_secs,
422        )
423        .await
424        .map_err(|e| anyhow::anyhow!("discover external issuer `{}`: {e}", entry.issuer_url))?;
425        tracing::info!(
426            target: "assay-engine",
427            issuer = %entry.issuer_url,
428            audience = ?entry.audience,
429            "trusted external OIDC issuer for JWT pass-through"
430        );
431        out.push(verifier);
432    }
433    Ok(out)
434}
435
436/// Issuer for JWTs minted via the `auth-jwt` module. Defaults to
437/// `<server.public_url>/auth` when unset, matching where the auth
438/// router is mounted.
439fn effective_issuer(cfg: &EngineConfig) -> String {
440    if let Some(issuer) = &cfg.auth.issuer {
441        return issuer.clone();
442    }
443    let base = cfg.server.public_url.trim_end_matches('/');
444    format!("{base}/auth")
445}
446
447/// Issuer the OIDC provider advertises in its discovery doc + the `iss`
448/// claim of every issued id_token. Defaults to the parent
449/// [`effective_issuer`] when no override is set.
450fn oidc_issuer(cfg: &EngineConfig) -> String {
451    cfg.auth
452        .oidc_provider
453        .issuer_override
454        .clone()
455        .unwrap_or_else(|| effective_issuer(cfg))
456}
457
458/// Parse `server.public_url` as a `url::Url`. Used by passkey RP setup
459/// (which wants the bare origin) — not by the OIDC provider, which
460/// needs the issuer URL (with `/auth`); see [`oidc_public_url`].
461#[cfg(feature = "auth-passkey")]
462fn parse_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
463    url::Url::parse(&cfg.server.public_url)
464        .map_err(|e| anyhow::anyhow!("server.public_url {:?}: {e}", cfg.server.public_url))
465}
466
467/// Base URL the OIDC provider exposes its endpoints at — same as
468/// [`oidc_issuer`] (which already accounts for the `/auth` mount
469/// prefix), parsed as a `url::Url`. Passed into `OidcProviderConfig`
470/// so `upstream_callback_url(...)` produces an absolute URI that
471/// matches the actual handler path.
472fn oidc_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
473    let issuer = oidc_issuer(cfg);
474    url::Url::parse(&issuer).map_err(|e| anyhow::anyhow!("oidc issuer {issuer:?}: {e}"))
475}
476
477/// Build a passkey manager from `auth.passkey` config. Returns `None`
478/// when the public_url isn't parseable as a URL with a host (passkeys
479/// require an origin) — we log + skip rather than fail boot.
480#[cfg(feature = "auth-passkey")]
481fn build_passkey_manager(
482    cfg: &EngineConfig,
483    users: Arc<dyn assay_auth::store::UserStore>,
484) -> Option<assay_auth::passkey::PasskeyManager> {
485    let url = match parse_public_url(cfg) {
486        Ok(u) => u,
487        Err(e) => {
488            tracing::warn!(?e, "passkeys disabled — bad public_url");
489            return None;
490        }
491    };
492    let host = match url.host_str() {
493        Some(h) => h.to_string(),
494        None => {
495            tracing::warn!("passkeys disabled — public_url has no host");
496            return None;
497        }
498    };
499    let pk_cfg = assay_auth::passkey::PasskeyConfig {
500        rp_id: cfg.auth.passkey.rp_id.clone().unwrap_or(host),
501        rp_name: cfg
502            .auth
503            .passkey
504            .rp_name
505            .clone()
506            .unwrap_or_else(|| "Assay".to_string()),
507        origin: url,
508    };
509    match assay_auth::passkey::PasskeyManager::new(pk_cfg, users) {
510        Ok(m) => Some(m),
511        Err(e) => {
512            tracing::warn!(?e, "passkeys disabled — manager construction failed");
513            None
514        }
515    }
516}
517
518// `run_with_store` (the previous private composition helper) is gone.
519// Its body lives in `embedded::compose` (this module's `embedded` sibling),
520// minus the final `server::serve` call. `pub async fn run` above
521// composes the engine via `embedded::build` and then binds + serves
522// the resulting `axum::Router` via `server::bind_and_serve`.