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 #[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 let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
148 ctx = ctx.with_dynamic(svc);
149 }
150 Ok(Some(ctx))
151}
152
153#[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#[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
453fn 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
464fn 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
475fn 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
482fn 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