1use 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
60pub 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#[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 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 let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
136 ctx = ctx.with_dynamic(svc);
137 }
138 Ok(Some(ctx))
139}
140
141#[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#[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
436fn effective_issuer(cfg: &EngineConfig) -> String {
440 if let Some(issuer) = &cfg.auth.issuer {
441 return issuer.clone();
442 }
443 let base = auth_public_url(cfg).trim_end_matches('/');
444 format!("{base}/auth")
445}
446
447fn auth_public_url(cfg: &EngineConfig) -> &str {
448 cfg.auth
449 .public_url
450 .as_deref()
451 .unwrap_or(&cfg.server.public_url)
452}
453
454fn oidc_issuer(cfg: &EngineConfig) -> String {
458 cfg.auth
459 .oidc_provider
460 .issuer_override
461 .clone()
462 .unwrap_or_else(|| effective_issuer(cfg))
463}
464
465fn parse_auth_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
469 let public_url = auth_public_url(cfg);
470 url::Url::parse(public_url).map_err(|e| anyhow::anyhow!("auth.public_url {public_url:?}: {e}"))
471}
472
473fn oidc_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
479 let issuer = oidc_issuer(cfg);
480 url::Url::parse(&issuer).map_err(|e| anyhow::anyhow!("oidc issuer {issuer:?}: {e}"))
481}
482
483#[cfg(feature = "auth-passkey")]
487fn build_passkey_manager(
488 cfg: &EngineConfig,
489 users: Arc<dyn assay_auth::store::UserStore>,
490) -> Option<assay_auth::passkey::PasskeyManager> {
491 let url = match parse_auth_public_url(cfg) {
492 Ok(u) => u,
493 Err(e) => {
494 tracing::warn!(?e, "passkeys disabled — bad public_url");
495 return None;
496 }
497 };
498 let host = match url.host_str() {
499 Some(h) => h.to_string(),
500 None => {
501 tracing::warn!("passkeys disabled — public_url has no host");
502 return None;
503 }
504 };
505 let pk_cfg = assay_auth::passkey::PasskeyConfig {
506 rp_id: cfg.auth.passkey.rp_id.clone().unwrap_or(host),
507 rp_name: cfg
508 .auth
509 .passkey
510 .rp_name
511 .clone()
512 .unwrap_or_else(|| "Assay".to_string()),
513 origin: url,
514 };
515 match assay_auth::passkey::PasskeyManager::new(pk_cfg, users) {
516 Ok(m) => Some(m),
517 Err(e) => {
518 tracing::warn!(?e, "passkeys disabled — manager construction failed");
519 None
520 }
521 }
522}
523
524#[cfg(test)]
525mod public_url_tests {
526 use super::*;
527
528 fn config(server_public_url: &str, auth_public_url: Option<&str>) -> EngineConfig {
529 let auth_public_url = auth_public_url
530 .map(|url| format!("public_url = \"{url}\""))
531 .unwrap_or_default();
532 toml::from_str(&format!(
533 r#"
534[server]
535bind_addr = "127.0.0.1:3000"
536public_url = "{server_public_url}"
537
538[backend]
539type = "sqlite"
540data_dir = ":memory:"
541
542[auth]
543{auth_public_url}
544"#
545 ))
546 .expect("valid engine config")
547 }
548
549 #[test]
550 fn auth_origin_defaults_to_engine_public_url() {
551 let cfg = config("https://engine.example.com", None);
552
553 assert_eq!(effective_issuer(&cfg), "https://engine.example.com/auth");
554 assert_eq!(
555 parse_auth_public_url(&cfg).unwrap().as_str(),
556 "https://engine.example.com/"
557 );
558 }
559
560 #[test]
561 fn auth_origin_override_drives_default_issuer_and_passkey_origin() {
562 let cfg = config(
563 "https://engine.example.com",
564 Some("https://auth.example.com"),
565 );
566
567 assert_eq!(effective_issuer(&cfg), "https://auth.example.com/auth");
568 assert_eq!(
569 parse_auth_public_url(&cfg).unwrap().as_str(),
570 "https://auth.example.com/"
571 );
572 }
573
574 #[test]
575 fn explicit_issuer_takes_precedence_over_auth_origin() {
576 let mut cfg = config(
577 "https://engine.example.com",
578 Some("https://auth.example.com"),
579 );
580 cfg.auth.issuer = Some("https://issuer.example.net/oauth".to_string());
581
582 assert_eq!(effective_issuer(&cfg), "https://issuer.example.net/oauth");
583 }
584
585 #[test]
586 fn invalid_auth_origin_is_rejected() {
587 let cfg = config("https://engine.example.com", Some("not a URL"));
588
589 let error = parse_auth_public_url(&cfg).unwrap_err();
590 assert!(error.to_string().contains("auth.public_url"));
591 }
592}
593
594