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 = cfg.server.public_url.trim_end_matches('/');
444 format!("{base}/auth")
445}
446
447fn 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#[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
467fn 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#[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