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        // Plan §S4 — seed default Zanzibar namespaces (vault,
114        // collection, kv_path, team, family, org). Idempotent.
115        // Builds the same backing PG store assay-auth uses; the
116        // namespace rows live in `auth.zanzibar_namespaces` regardless
117        // of which crate writes them.
118        #[cfg(feature = "auth-zanzibar")]
119        {
120            let z = assay_auth::zanzibar::PostgresZanzibarStore::new(pool.clone());
121            assay_vault::zanzibar::seed_default_namespaces(&z)
122                .await
123                .map_err(|e| anyhow::anyhow!("seed vault zanzibar namespaces (pg): {e}"))?;
124        }
125    }
126    #[cfg(feature = "vault-share")]
127    {
128        let kp = assay_vault::store::postgres::load_or_init_biscuit_root_postgres(pool)
129            .await
130            .map_err(|e| anyhow::anyhow!("vault biscuit root bootstrap (pg): {e}"))?;
131        let revs = std::sync::Arc::new(assay_vault::store::postgres::PgRevocationStore::new(
132            pool.clone(),
133        ));
134        let svc = assay_vault::share::ShareService::new(kp, revs);
135        ctx = ctx.with_share(svc);
136    }
137    #[cfg(feature = "vault-dynamic-postgres")]
138    {
139        let leases = std::sync::Arc::new(assay_vault::store::postgres::PgLeaseStore::new(
140            pool.clone(),
141        ));
142        let registry = assay_vault::dynamic::DynamicCredsRegistry::new();
143        // Phase 5 default-config: registry is empty until an operator
144        // configures providers via /dynamic/* admin routes (or in
145        // future, engine.toml). The dispatcher returns NotFound for
146        // unknown providers, which surfaces as 404 to the caller.
147        let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
148        ctx = ctx.with_dynamic(svc);
149    }
150    Ok(Some(ctx))
151}
152
153/// SQLite mirror of [`build_vault_ctx_pg`].
154#[cfg(all(feature = "vault", feature = "backend-sqlite"))]
155async fn build_vault_ctx_sqlite(
156    modules: &[String],
157    pool: &sqlx::SqlitePool,
158) -> anyhow::Result<Option<assay_vault::VaultCtx>> {
159    if !modules.iter().any(|m| m == "vault") {
160        return Ok(None);
161    }
162    let kek = assay_vault::crypto::kek_store::load_or_init_sqlite(pool)
163        .await
164        .map_err(|e| anyhow::anyhow!("vault KEK bootstrap (sqlite): {e}"))?;
165    let mut ctx = assay_vault::VaultCtx::new()
166        .with_kek(kek)
167        .with_kv(assay_vault::store::sqlite::SqliteKvStore::new(pool.clone()))
168        .with_transit(assay_vault::store::sqlite::SqliteTransitStore::new(
169            pool.clone(),
170        ));
171    #[cfg(feature = "vault-sealing-shamir")]
172    {
173        ctx = ctx.with_seal_store(assay_vault::store::sqlite::SqliteSealStore::new(
174            pool.clone(),
175        ));
176    }
177    #[cfg(feature = "vault-collections")]
178    {
179        ctx = ctx
180            .with_personal_vaults(assay_vault::store::sqlite::SqlitePersonalVaultStore::new(
181                pool.clone(),
182            ))
183            .with_collections(assay_vault::store::sqlite::SqliteCollectionStore::new(
184                pool.clone(),
185            ))
186            .with_items(assay_vault::store::sqlite::SqliteItemStore::new(
187                pool.clone(),
188            ))
189            .with_folders(assay_vault::store::sqlite::SqliteFolderStore::new(
190                pool.clone(),
191            ));
192        #[cfg(feature = "auth-zanzibar")]
193        {
194            let z = assay_auth::zanzibar::SqliteZanzibarStore::new(pool.clone());
195            assay_vault::zanzibar::seed_default_namespaces(&z)
196                .await
197                .map_err(|e| anyhow::anyhow!("seed vault zanzibar namespaces (sqlite): {e}"))?;
198        }
199    }
200    #[cfg(feature = "vault-share")]
201    {
202        let kp = assay_vault::store::sqlite::load_or_init_biscuit_root_sqlite(pool)
203            .await
204            .map_err(|e| anyhow::anyhow!("vault biscuit root bootstrap (sqlite): {e}"))?;
205        let revs = std::sync::Arc::new(assay_vault::store::sqlite::SqliteRevocationStore::new(
206            pool.clone(),
207        ));
208        let svc = assay_vault::share::ShareService::new(kp, revs);
209        ctx = ctx.with_share(svc);
210    }
211    #[cfg(feature = "vault-dynamic-postgres")]
212    {
213        let leases = std::sync::Arc::new(assay_vault::store::sqlite::SqliteLeaseStore::new(
214            pool.clone(),
215        ));
216        let registry = assay_vault::dynamic::DynamicCredsRegistry::new();
217        let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
218        ctx = ctx.with_dynamic(svc);
219    }
220    Ok(Some(ctx))
221}
222
223#[cfg(feature = "backend-postgres")]
224async fn build_auth_ctx_pg(
225    cfg: &EngineConfig,
226    pool: &sqlx::PgPool,
227) -> anyhow::Result<assay_auth::AuthCtx> {
228    use assay_auth::store::{PostgresSessionStore, PostgresUserStore};
229    let users = PostgresUserStore::new(pool.clone()).into_dyn();
230    let sessions = PostgresSessionStore::new(pool.clone()).into_dyn();
231    let mut ctx = assay_auth::AuthCtx::new(users.clone(), sessions);
232
233    let biscuit = assay_auth::biscuit::load_or_init_postgres(pool)
234        .await
235        .map_err(|e| anyhow::anyhow!("biscuit root key (pg): {e}"))?;
236    ctx = ctx.with_biscuit(biscuit);
237
238    #[cfg(feature = "auth-jwt")]
239    {
240        let issuer = effective_issuer(cfg);
241        let audience = if cfg.auth.audience.is_empty() {
242            vec![issuer.clone()]
243        } else {
244            cfg.auth.audience.clone()
245        };
246        let jwt = assay_auth::jwt::JwtConfig::new(issuer.clone(), audience);
247        if let Err(e) = jwt.load_from_postgres(pool).await {
248            tracing::warn!(?e, "no JWKS rows yet; rotating to seed first key");
249            jwt.rotate_postgres(pool)
250                .await
251                .map_err(|e| anyhow::anyhow!("seed JWKS (pg): {e}"))?;
252        }
253        if jwt.active_kid().is_none() {
254            jwt.rotate_postgres(pool)
255                .await
256                .map_err(|e| anyhow::anyhow!("seed JWKS (pg): {e}"))?;
257        }
258        ctx = ctx.with_jwt(jwt);
259
260        ctx = ctx.with_external_issuers(discover_external_issuers(cfg).await?);
261    }
262
263    #[cfg(feature = "auth-oidc")]
264    {
265        ctx = ctx.with_oidc(assay_auth::oidc::OidcRegistry::new());
266    }
267
268    #[cfg(feature = "auth-passkey")]
269    if let Some(passkey_mgr) = build_passkey_manager(cfg, users.clone()) {
270        ctx = ctx.with_passkeys(passkey_mgr);
271    }
272
273    #[cfg(feature = "auth-zanzibar")]
274    {
275        let zanzibar: Arc<dyn assay_auth::zanzibar::ZanzibarStore> = Arc::new(
276            assay_auth::zanzibar::PostgresZanzibarStore::new(pool.clone()),
277        );
278        ctx = ctx.with_zanzibar(zanzibar);
279    }
280
281    #[cfg(feature = "auth-oidc-provider")]
282    if cfg.auth.oidc_provider.enabled {
283        let issuer = oidc_issuer(cfg);
284        let public_url = parse_public_url(cfg)?;
285        let provider = assay_auth::oidc_provider::OidcProviderConfig::new(
286            issuer,
287            public_url,
288            assay_auth::oidc_provider::PostgresOidcClientStore::new(pool.clone()).into_dyn(),
289            assay_auth::oidc_provider::PostgresOidcUpstreamStore::new(pool.clone()).into_dyn(),
290            assay_auth::oidc_provider::PostgresOidcCodeStore::new(pool.clone()).into_dyn(),
291            assay_auth::oidc_provider::PostgresOidcRefreshStore::new(pool.clone()).into_dyn(),
292            assay_auth::oidc_provider::PostgresOidcSessionStore::new(pool.clone()).into_dyn(),
293            assay_auth::oidc_provider::PostgresOidcConsentStore::new(pool.clone()).into_dyn(),
294            assay_auth::oidc_provider::PostgresOidcUpstreamStateStore::new(pool.clone()).into_dyn(),
295        )
296        .with_jwks_source(assay_auth::oidc_provider::JwksSource::Postgres(
297            pool.clone(),
298        ));
299        ctx = ctx.with_oidc_provider(provider);
300
301        if let (Some(registry), Some(provider)) = (&ctx.oidc, &ctx.oidc_provider) {
302            match provider.upstream.list().await {
303                Ok(rows) => {
304                    for row in rows {
305                        assay_auth::oidc_provider::sync_upstream_to_registry(
306                            registry,
307                            &row,
308                            &provider.public_url,
309                        )
310                        .await;
311                    }
312                }
313                Err(e) => {
314                    tracing::warn!("failed to list upstream providers at boot: {e}");
315                }
316            }
317        }
318    }
319
320    Ok(ctx)
321}
322
323#[cfg(feature = "backend-sqlite")]
324async fn build_auth_ctx_sqlite(
325    cfg: &EngineConfig,
326    pool: &sqlx::SqlitePool,
327) -> anyhow::Result<assay_auth::AuthCtx> {
328    use assay_auth::store::{SqliteSessionStore, SqliteUserStore};
329    let users = SqliteUserStore::new(pool.clone()).into_dyn();
330    let sessions = SqliteSessionStore::new(pool.clone()).into_dyn();
331    let mut ctx = assay_auth::AuthCtx::new(users.clone(), sessions);
332
333    let biscuit = assay_auth::biscuit::load_or_init_sqlite(pool)
334        .await
335        .map_err(|e| anyhow::anyhow!("biscuit root key (sqlite): {e}"))?;
336    ctx = ctx.with_biscuit(biscuit);
337
338    #[cfg(feature = "auth-jwt")]
339    {
340        let issuer = effective_issuer(cfg);
341        let audience = if cfg.auth.audience.is_empty() {
342            vec![issuer.clone()]
343        } else {
344            cfg.auth.audience.clone()
345        };
346        let jwt = assay_auth::jwt::JwtConfig::new(issuer.clone(), audience);
347        if let Err(e) = jwt.load_from_sqlite(pool).await {
348            tracing::warn!(?e, "no JWKS rows yet; rotating to seed first key");
349            jwt.rotate_sqlite(pool)
350                .await
351                .map_err(|e| anyhow::anyhow!("seed JWKS (sqlite): {e}"))?;
352        }
353        if jwt.active_kid().is_none() {
354            jwt.rotate_sqlite(pool)
355                .await
356                .map_err(|e| anyhow::anyhow!("seed JWKS (sqlite): {e}"))?;
357        }
358        ctx = ctx.with_jwt(jwt);
359
360        ctx = ctx.with_external_issuers(discover_external_issuers(cfg).await?);
361    }
362
363    #[cfg(feature = "auth-oidc")]
364    {
365        ctx = ctx.with_oidc(assay_auth::oidc::OidcRegistry::new());
366    }
367
368    #[cfg(feature = "auth-passkey")]
369    if let Some(passkey_mgr) = build_passkey_manager(cfg, users.clone()) {
370        ctx = ctx.with_passkeys(passkey_mgr);
371    }
372
373    #[cfg(feature = "auth-zanzibar")]
374    {
375        let zanzibar: Arc<dyn assay_auth::zanzibar::ZanzibarStore> =
376            Arc::new(assay_auth::zanzibar::SqliteZanzibarStore::new(pool.clone()));
377        ctx = ctx.with_zanzibar(zanzibar);
378    }
379
380    #[cfg(feature = "auth-oidc-provider")]
381    if cfg.auth.oidc_provider.enabled {
382        let issuer = oidc_issuer(cfg);
383        let public_url = parse_public_url(cfg)?;
384        let provider = assay_auth::oidc_provider::OidcProviderConfig::new(
385            issuer,
386            public_url,
387            assay_auth::oidc_provider::SqliteOidcClientStore::new(pool.clone()).into_dyn(),
388            assay_auth::oidc_provider::SqliteOidcUpstreamStore::new(pool.clone()).into_dyn(),
389            assay_auth::oidc_provider::SqliteOidcCodeStore::new(pool.clone()).into_dyn(),
390            assay_auth::oidc_provider::SqliteOidcRefreshStore::new(pool.clone()).into_dyn(),
391            assay_auth::oidc_provider::SqliteOidcSessionStore::new(pool.clone()).into_dyn(),
392            assay_auth::oidc_provider::SqliteOidcConsentStore::new(pool.clone()).into_dyn(),
393            assay_auth::oidc_provider::SqliteOidcUpstreamStateStore::new(pool.clone()).into_dyn(),
394        )
395        .with_jwks_source(assay_auth::oidc_provider::JwksSource::Sqlite(pool.clone()));
396        ctx = ctx.with_oidc_provider(provider);
397
398        if let (Some(registry), Some(provider)) = (&ctx.oidc, &ctx.oidc_provider) {
399            match provider.upstream.list().await {
400                Ok(rows) => {
401                    for row in rows {
402                        assay_auth::oidc_provider::sync_upstream_to_registry(
403                            registry,
404                            &row,
405                            &provider.public_url,
406                        )
407                        .await;
408                    }
409                }
410                Err(e) => {
411                    tracing::warn!("failed to list upstream providers at boot: {e}");
412                }
413            }
414        }
415    }
416
417    Ok(ctx)
418}
419
420/// Discover each configured external OIDC issuer once at boot and
421/// hand back ready-to-use verifiers. Each verifier owns a background
422/// task that refreshes its JWKS on the configured interval.
423///
424/// Errors here are fatal — if Hydra (or whichever IdP) is unreachable
425/// at boot the engine shouldn't pretend it can validate tokens. The
426/// alternative (silently degrading to "no external issuer trusted")
427/// would surface as 401s and look like a session bug.
428#[cfg(feature = "auth-jwt")]
429async fn discover_external_issuers(
430    cfg: &EngineConfig,
431) -> anyhow::Result<Vec<assay_auth::external_jwt::ExternalJwtIssuer>> {
432    let entries = cfg.auth.external_issuers();
433    let mut out = Vec::with_capacity(entries.len());
434    for entry in entries {
435        let verifier = assay_auth::external_jwt::ExternalJwtIssuer::discover(
436            entry.issuer_url.clone(),
437            entry.audience.clone(),
438            entry.jwks_refresh_secs,
439        )
440        .await
441        .map_err(|e| anyhow::anyhow!("discover external issuer `{}`: {e}", entry.issuer_url))?;
442        tracing::info!(
443            target: "assay-engine",
444            issuer = %entry.issuer_url,
445            audience = ?entry.audience,
446            "trusted external OIDC issuer for JWT pass-through"
447        );
448        out.push(verifier);
449    }
450    Ok(out)
451}
452
453/// Issuer for JWTs minted via the `auth-jwt` module. Defaults to
454/// `<server.public_url>/auth` when unset, matching where the auth
455/// router is mounted.
456fn effective_issuer(cfg: &EngineConfig) -> String {
457    if let Some(issuer) = &cfg.auth.issuer {
458        return issuer.clone();
459    }
460    let base = cfg.server.public_url.trim_end_matches('/');
461    format!("{base}/auth")
462}
463
464/// Issuer the OIDC provider advertises in its discovery doc + the `iss`
465/// claim of every issued id_token. Defaults to the parent
466/// [`effective_issuer`] when no override is set.
467fn oidc_issuer(cfg: &EngineConfig) -> String {
468    cfg.auth
469        .oidc_provider
470        .issuer_override
471        .clone()
472        .unwrap_or_else(|| effective_issuer(cfg))
473}
474
475/// Parse `server.public_url` as a `url::Url`. Used by the OIDC provider
476/// to derive default redirect targets and by passkey RP setup.
477fn parse_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
478    url::Url::parse(&cfg.server.public_url)
479        .map_err(|e| anyhow::anyhow!("server.public_url {:?}: {e}", cfg.server.public_url))
480}
481
482/// Build a passkey manager from `auth.passkey` config. Returns `None`
483/// when the public_url isn't parseable as a URL with a host (passkeys
484/// require an origin) — we log + skip rather than fail boot.
485fn build_passkey_manager(
486    cfg: &EngineConfig,
487    users: Arc<dyn assay_auth::store::UserStore>,
488) -> Option<assay_auth::passkey::PasskeyManager> {
489    let url = match parse_public_url(cfg) {
490        Ok(u) => u,
491        Err(e) => {
492            tracing::warn!(?e, "passkeys disabled — bad public_url");
493            return None;
494        }
495    };
496    let host = match url.host_str() {
497        Some(h) => h.to_string(),
498        None => {
499            tracing::warn!("passkeys disabled — public_url has no host");
500            return None;
501        }
502    };
503    let pk_cfg = assay_auth::passkey::PasskeyConfig {
504        rp_id: cfg.auth.passkey.rp_id.clone().unwrap_or(host),
505        rp_name: cfg
506            .auth
507            .passkey
508            .rp_name
509            .clone()
510            .unwrap_or_else(|| "Assay".to_string()),
511        origin: url,
512    };
513    match assay_auth::passkey::PasskeyManager::new(pk_cfg, users) {
514        Ok(m) => Some(m),
515        Err(e) => {
516            tracing::warn!(?e, "passkeys disabled — manager construction failed");
517            None
518        }
519    }
520}
521
522// `run_with_store` (the previous private composition helper) is gone.
523// Its body lives in `embedded::compose` (this module's `embedded` sibling),
524// minus the final `server::serve` call. `pub async fn run` above
525// composes the engine via `embedded::build` and then binds + serves
526// the resulting `axum::Router` via `server::bind_and_serve`.