assay_engine/config.rs
1//! Engine configuration loaded from TOML.
2//!
3//! Phase 8 wires in `AuthConfig` so the engine binary can compose an
4//! `assay_auth::AuthCtx` per-deployment (issuer, OIDC provider toggle,
5//! session/cookie shape). When `auth` isn't compiled in (Cargo feature
6//! off) the auth section is parsed but never read — keeping the TOML
7//! shape stable across feature configurations.
8//!
9//! Env-var substitution: `${VAR}` and `${VAR:-default}` references in
10//! the TOML are expanded against the process environment before parsing
11//! (added in 0.3.1). This keeps secrets out of config files when the
12//! engine runs under K8s/systemd/etc. — the typical pattern is
13//! `url = "${DATABASE_URL}"` with `DATABASE_URL` injected from a
14//! Secret/EnvironmentFile.
15
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
20#[non_exhaustive]
21pub struct EngineConfig {
22 pub server: ServerConfig,
23 pub backend: BackendConfig,
24 #[serde(default)]
25 pub workflow: WorkflowConfig,
26 #[serde(default)]
27 pub auth: AuthConfig,
28 #[serde(default)]
29 pub dashboard: DashboardConfig,
30 #[serde(default)]
31 pub logging: LoggingConfig,
32 /// TTL in seconds for the engine_events outbox. Rows older than this
33 /// are pruned hourly by the cleanup loop. Default 3 days.
34 #[serde(default = "default_engine_events_ttl_secs")]
35 pub engine_events_ttl_secs: u64,
36 /// Modules to flip from `enabled = FALSE` to `enabled = TRUE` on
37 /// first boot when they're compiled in. Empty by default — operators
38 /// of existing v0.1.2 deployments shouldn't get unexpected auth
39 /// migrations on upgrade. Local-dev convenience: set to
40 /// `["auth"]` in `engine.local.toml` to flip auth on without an
41 /// extra step.
42 #[serde(default)]
43 pub auto_enable_modules: Vec<String>,
44}
45
46fn default_engine_events_ttl_secs() -> u64 {
47 3 * 86_400
48}
49
50#[derive(Clone, Debug, Deserialize, Serialize)]
51#[non_exhaustive]
52pub struct ServerConfig {
53 #[serde(default = "default_bind_addr")]
54 pub bind_addr: String,
55 /// Operator-supplied canonical URL the engine is reached at — used
56 /// as the OIDC `iss` claim, biscuit token issuer, passkey origin,
57 /// and the base for federation callbacks. Defaults to the bind addr
58 /// over plain HTTP for local dev convenience; production deployments
59 /// MUST override this with the public HTTPS URL.
60 #[serde(default = "default_public_url")]
61 pub public_url: String,
62}
63
64fn default_bind_addr() -> String {
65 "0.0.0.0:3000".to_string()
66}
67
68fn default_public_url() -> String {
69 "http://localhost:3000".to_string()
70}
71
72#[derive(Clone, Debug, Deserialize, Serialize)]
73#[serde(tag = "type", rename_all = "lowercase")]
74#[non_exhaustive]
75pub enum BackendConfig {
76 Postgres {
77 /// Postgres connection URL, e.g. `postgres://user:pass@host:5432/db`.
78 /// PostgreSQL 18 is the minimum supported version.
79 url: String,
80 },
81 Sqlite {
82 /// Directory holding the per-module SQLite files
83 /// (`<data_dir>/engine.db`, `<data_dir>/workflow.db`, …). Created
84 /// on startup if missing. Defaults to `./data`. Use `:memory:`
85 /// in `path` (legacy) or set `data_dir = ":memory:"` to keep the
86 /// engine purely in-memory for tests.
87 #[serde(default = "default_data_dir")]
88 data_dir: String,
89 /// Legacy single-file SQLite path. Deprecated in v0.1.2 — when
90 /// set, the engine logs a deprecation notice and treats it as
91 /// `data_dir = parent(path)` so existing configs keep working
92 /// during the transition.
93 #[serde(default)]
94 path: Option<String>,
95 },
96}
97
98fn default_data_dir() -> String {
99 "./data".to_string()
100}
101
102impl BackendConfig {
103 /// Resolve the effective data directory for SQLite. PG returns `None`.
104 pub fn sqlite_data_dir(&self) -> Option<String> {
105 match self {
106 Self::Sqlite { data_dir, path } => {
107 // Legacy `path` wins for backwards compat — treat the
108 // parent dir as the new data_dir so existing v0.1.1
109 // configs migrate without surprise.
110 if let Some(p) = path {
111 let parent = std::path::Path::new(p)
112 .parent()
113 .map(|p| p.display().to_string())
114 .filter(|s| !s.is_empty());
115 Some(parent.unwrap_or_else(|| data_dir.clone()))
116 } else {
117 Some(data_dir.clone())
118 }
119 }
120 Self::Postgres { .. } => None,
121 }
122 }
123}
124
125#[derive(Clone, Debug, Default, Deserialize, Serialize)]
126#[non_exhaustive]
127pub struct WorkflowConfig {
128 #[serde(default = "default_true")]
129 pub enabled: bool,
130}
131
132/// Auth-module deployment shape. Read by the engine binary when the
133/// `auth` Cargo feature is compiled in AND `engine.modules.auth.enabled`
134/// is TRUE; otherwise the defaults are harmless.
135#[derive(Clone, Debug, Default, Deserialize, Serialize)]
136#[non_exhaustive]
137pub struct AuthConfig {
138 /// JWT issuer + OIDC `iss` claim. Defaults to
139 /// `<server.public_url>/auth` when unset, which matches the route
140 /// mount point.
141 pub issuer: Option<String>,
142 /// JWT audience list — also used by the OIDC provider when minting
143 /// access_tokens for resource servers. Defaults to `[issuer]`.
144 #[serde(default)]
145 pub audience: Vec<String>,
146 #[serde(default)]
147 pub session: AuthSessionConfig,
148 #[serde(default)]
149 pub passkey: AuthPasskeyConfig,
150 #[serde(default)]
151 pub oidc_provider: AuthOidcProviderConfig,
152 /// Admin API keys — comma-separated bearer tokens that grant access
153 /// to `/admin/*` routes. Operators rotate these via the engine
154 /// config. Per-token, no expiry; for fancier admin auth (Zanzibar
155 /// roles, session-based admin) see plan 12c § 6.7. Empty list locks
156 /// admin routes entirely (404 → 401).
157 #[serde(default)]
158 pub admin_api_keys: Vec<String>,
159 /// External OIDC issuers trusted to mint JWTs the engine accepts
160 /// pass-through (v0.3.2). Each entry's JWKS is discovered via
161 /// `<issuer_url>/.well-known/openid-configuration` at boot and
162 /// refreshed periodically thereafter. Tokens whose `iss` claim
163 /// matches a configured issuer are verified against that issuer's
164 /// keys; everything else falls through to the engine's internal
165 /// JWT path. When this list is non-empty, the engine boots without
166 /// requiring operator users / `admin_api_keys` — the upstream IdP
167 /// is the source of truth for identity.
168 ///
169 /// Mirrors the v0.12.1 `--auth-issuer` / `--auth-audience` CLI
170 /// flags in the new TOML config shape. Multiple issuers are allowed
171 /// for deployments that span more than one IdP.
172 ///
173 /// Field is private so future entries (per-issuer policy, claim
174 /// mappers, etc.) can be added without breaking downstream
175 /// construction. Read via [`AuthConfig::external_issuers`].
176 #[serde(default)]
177 external_issuers: Vec<ExternalIssuerConfig>,
178}
179
180impl AuthConfig {
181 /// Read access to the parsed `[[auth.external_issuers]]` blocks.
182 pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
183 &self.external_issuers
184 }
185}
186
187/// One trusted external OIDC issuer for pass-through JWT validation.
188#[derive(Clone, Debug, Default, Deserialize, Serialize)]
189#[non_exhaustive]
190pub struct ExternalIssuerConfig {
191 /// Issuer URL — the value the JWT's `iss` claim is matched against
192 /// and the base for `<issuer_url>/.well-known/openid-configuration`
193 /// discovery. Trailing slashes are normalized.
194 pub issuer_url: String,
195 /// Accepted `aud` claim values. A token whose `aud` isn't in this
196 /// list is rejected. Empty list = audience check disabled (NOT
197 /// recommended; set explicitly per deployment).
198 #[serde(default)]
199 pub audience: Vec<String>,
200 /// JWKS refresh interval in seconds (background task). Default 3600
201 /// (1 hour). Minimum effective value 60 seconds — anything smaller
202 /// is clamped to avoid hammering the upstream's JWKS endpoint.
203 #[serde(default = "default_jwks_refresh_secs")]
204 pub jwks_refresh_secs: u64,
205}
206
207fn default_jwks_refresh_secs() -> u64 {
208 3600
209}
210
211/// Session module knobs.
212#[derive(Clone, Debug, Default, Deserialize, Serialize)]
213#[non_exhaustive]
214pub struct AuthSessionConfig {
215 /// Default session lifetime in seconds. `None` ⇒ uses the
216 /// `assay_auth::session::DEFAULT_SESSION_DURATION` (30 days).
217 pub ttl_seconds: Option<u64>,
218}
219
220/// WebAuthn / passkey module knobs.
221#[derive(Clone, Debug, Default, Deserialize, Serialize)]
222#[non_exhaustive]
223pub struct AuthPasskeyConfig {
224 /// Relying-party id — the host (no scheme/port) the browser will
225 /// scope passkeys to. Defaults to the host of `server.public_url`.
226 pub rp_id: Option<String>,
227 /// Human-readable label browsers show. Defaults to `"Assay"`.
228 pub rp_name: Option<String>,
229}
230
231/// OIDC provider knobs.
232#[derive(Clone, Debug, Default, Deserialize, Serialize)]
233#[non_exhaustive]
234pub struct AuthOidcProviderConfig {
235 /// Whether the OIDC provider routes (/authorize /token /userinfo …)
236 /// are mounted. Defaults to `true` when the Cargo feature is on.
237 #[serde(default = "default_true")]
238 pub enabled: bool,
239 /// Override the issuer URL used by the OIDC provider. Defaults to
240 /// the parent [`AuthConfig::issuer`] when unset.
241 pub issuer_override: Option<String>,
242 /// `true` → federation callback creates an `auth.users` row on
243 /// first sign-in for a new upstream identity (open
244 /// signup; legacy library default — kept as the default
245 /// here so omitted config does not silently flip
246 /// existing deployments into invite-only).
247 /// `false` → callback looks up by email (and requires the
248 /// upstream `email_verified` claim); missing rows
249 /// return 403. Operators pre-populate `auth.users` via
250 /// the admin API or the sysops `/auth/users` page.
251 /// Recommended for shared / multi-tenant deployments —
252 /// must be set explicitly.
253 #[serde(default = "default_true")]
254 pub auto_provision: bool,
255}
256
257#[derive(Clone, Debug, Deserialize, Serialize)]
258#[non_exhaustive]
259pub struct DashboardConfig {
260 #[serde(default = "default_true")]
261 pub enabled: bool,
262}
263
264impl Default for DashboardConfig {
265 fn default() -> Self {
266 // When the `[dashboard]` section is omitted entirely from
267 // engine.toml, serde calls Default::default() — and bool's
268 // derived default is `false`. We want `enabled: true` here so
269 // a fresh engine.toml without a [dashboard] section still
270 // mounts the SPAs out of the box.
271 Self { enabled: true }
272 }
273}
274
275#[derive(Clone, Debug, Deserialize, Serialize)]
276#[non_exhaustive]
277pub struct LoggingConfig {
278 #[serde(default = "default_log_level")]
279 pub level: String,
280 #[serde(default = "default_log_format")]
281 pub format: String,
282}
283
284impl Default for LoggingConfig {
285 fn default() -> Self {
286 Self {
287 level: default_log_level(),
288 format: default_log_format(),
289 }
290 }
291}
292
293fn default_true() -> bool {
294 true
295}
296
297fn default_log_level() -> String {
298 "info".to_string()
299}
300
301fn default_log_format() -> String {
302 "pretty".to_string()
303}
304
305impl EngineConfig {
306 /// Load `engine.toml`. String fields support `${VAR}` and
307 /// `${VAR:-default}` env-var references; references with no default
308 /// error out at load time when the variable is unset. Bracket-less
309 /// `$VAR` is left untouched, and `${...}` whose contents aren't a
310 /// valid identifier are passed through verbatim.
311 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
312 let raw = std::fs::read_to_string(path)
313 .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
314 let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
315 .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
316 let cfg: Self = toml::from_str(&expanded)
317 .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
318 Ok(cfg)
319 }
320}
321
322/// Expand `${VAR}` and `${VAR:-default}` references in `raw` using
323/// `lookup` to resolve names. The lookup-by-closure shape keeps this
324/// pure for unit tests (the binary path uses `std::env::var`).
325///
326/// Behavior:
327/// - `${VAR}` → value if set, error if unset.
328/// - `${VAR:-default}` → value if set, else the default (which may be empty).
329/// - Bracket-less `$VAR` is untouched.
330/// - `${...}` whose contents aren't a valid identifier are passed
331/// through verbatim — keeps non-substitution `${...}` literals usable
332/// in odd field values without false positives.
333fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
334where
335 F: Fn(&str) -> Option<String>,
336{
337 let mut out = String::with_capacity(raw.len());
338 let mut rest = raw;
339 while let Some(idx) = rest.find("${") {
340 out.push_str(&rest[..idx]);
341 let after_open = &rest[idx + 2..];
342 let close_idx = after_open
343 .find('}')
344 .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
345 let inner = &after_open[..close_idx];
346 let (var_name, default) = match inner.split_once(":-") {
347 Some((n, d)) => (n, Some(d)),
348 None => (inner, None),
349 };
350 if !is_valid_var_name(var_name) {
351 // Not a valid identifier — pass the whole `${...}` through.
352 out.push_str("${");
353 out.push_str(inner);
354 out.push('}');
355 } else {
356 match lookup(var_name) {
357 Some(val) => out.push_str(&val),
358 None => match default {
359 Some(def) => out.push_str(def),
360 None => {
361 return Err(anyhow::anyhow!(
362 "env var `{}` is not set and has no default",
363 var_name
364 ));
365 }
366 },
367 }
368 }
369 rest = &after_open[close_idx + 1..];
370 }
371 out.push_str(rest);
372 Ok(out)
373}
374
375fn is_valid_var_name(s: &str) -> bool {
376 let mut chars = s.chars();
377 match chars.next() {
378 Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
379 _ => return false,
380 }
381 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
389 move |name: &str| {
390 map.iter()
391 .find(|(k, _)| *k == name)
392 .map(|(_, v)| (*v).to_string())
393 }
394 }
395
396 #[test]
397 fn no_substitution_passes_through() {
398 let s = "plain string with $literal but no expansion markers";
399 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
400 }
401
402 #[test]
403 fn substitutes_set_var() {
404 let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
405 assert_eq!(out, "value=hello");
406 }
407
408 #[test]
409 fn errors_on_unset_var_with_no_default() {
410 let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
411 assert!(err.to_string().contains("MISSING"));
412 }
413
414 #[test]
415 fn falls_back_to_default_when_unset() {
416 let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
417 assert_eq!(out, "fallback");
418 }
419
420 #[test]
421 fn ignores_default_when_var_set() {
422 let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
423 assert_eq!(out, "actual");
424 }
425
426 #[test]
427 fn empty_default_yields_empty_string() {
428 let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
429 assert_eq!(out, "[]");
430 }
431
432 #[test]
433 fn substitutes_multiple_vars_in_one_string() {
434 let out = expand_env_vars(
435 "postgres://u:p@${HOST}:${PORT}/x",
436 lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
437 )
438 .unwrap();
439 assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
440 }
441
442 #[test]
443 fn dollar_without_braces_passes_through() {
444 // Bracket-less `$IDENT` is intentionally left alone — only the
445 // `${...}` form is treated as an env reference.
446 let s = "$HOME and $USER stay literal";
447 let out = expand_env_vars(s, lookup_from(&[])).unwrap();
448 assert_eq!(out, s);
449 }
450
451 #[test]
452 fn invalid_identifier_passes_through_verbatim() {
453 // Digit-leading is not a valid identifier; `${1NOT_VALID}` stays literal.
454 let s = "${1NOT_VALID}";
455 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
456 }
457
458 #[test]
459 fn unclosed_brace_errors() {
460 let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
461 assert!(err.to_string().contains("unclosed"));
462 }
463
464 #[test]
465 fn substitutes_inside_toml_string_values() {
466 let toml_input = r#"
467[backend]
468type = "postgres"
469url = "${DB}"
470"#;
471 let expanded =
472 expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
473 assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
474 }
475
476 #[test]
477 fn is_valid_var_name_accepts_typical_names() {
478 assert!(is_valid_var_name("DATABASE_URL"));
479 assert!(is_valid_var_name("_PRIVATE"));
480 assert!(is_valid_var_name("X"));
481 assert!(is_valid_var_name("X1"));
482 }
483
484 #[test]
485 fn is_valid_var_name_rejects_bad_names() {
486 assert!(!is_valid_var_name(""));
487 assert!(!is_valid_var_name("1LEADING_DIGIT"));
488 assert!(!is_valid_var_name("HAS SPACE"));
489 assert!(!is_valid_var_name("HAS-DASH"));
490 assert!(!is_valid_var_name("HAS.DOT"));
491 }
492
493 #[test]
494 fn from_file_loads_static_toml() {
495 // Integration sanity that the from_file path still works after the
496 // expansion step is wired in. Uses a config with no env-var
497 // references to keep the test hermetic.
498 let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
499 std::fs::write(
500 &path,
501 r#"
502[server]
503bind_addr = "127.0.0.1:3000"
504
505[backend]
506type = "sqlite"
507data_dir = "/tmp/assay-engine-test-data-static"
508"#,
509 )
510 .unwrap();
511 let cfg = EngineConfig::from_file(&path).unwrap();
512 let _ = std::fs::remove_file(&path);
513 match cfg.backend {
514 BackendConfig::Sqlite { ref data_dir, .. } => {
515 assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
516 }
517 _ => panic!("expected sqlite backend"),
518 }
519 }
520}