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