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_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
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(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 #[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 let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
144 ctx = ctx.with_dynamic(svc);
145 }
146 Ok(Some(ctx))
147}
148
149#[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#[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
404fn 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
415fn 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
426fn 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
433fn 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