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 for the engine API and dashboard.
56 /// Auth defaults to this origin too, but can use a dedicated hostname
57 /// through `auth.public_url`. Defaults to the bind address over plain
58 /// HTTP for local development; production deployments MUST override it
59 /// 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 /// Canonical browser-facing origin for the auth surface. Defaults to
139 /// `server.public_url` when unset. This allows one engine deployment to
140 /// expose auth on a dedicated hostname without changing its API origin.
141 pub public_url: Option<String>,
142 /// JWT issuer + OIDC `iss` claim. Defaults to
143 /// `<auth.public_url>/auth` when unset, which matches the route
144 /// mount point.
145 pub issuer: Option<String>,
146 /// JWT audience list — also used by the OIDC provider when minting
147 /// access_tokens for resource servers. Defaults to `[issuer]`.
148 #[serde(default)]
149 pub audience: Vec<String>,
150 #[serde(default)]
151 pub session: AuthSessionConfig,
152 #[serde(default)]
153 pub passkey: AuthPasskeyConfig,
154 #[serde(default)]
155 pub oidc_provider: AuthOidcProviderConfig,
156 /// Admin API keys — comma-separated bearer tokens that grant access
157 /// to `/admin/*` routes. Operators rotate these via the engine
158 /// config. Per-token, no expiry; for fancier admin auth (Zanzibar
159 /// roles, session-based admin) see plan 12c § 6.7. Empty list locks
160 /// admin routes entirely (404 → 401).
161 #[serde(default)]
162 pub admin_api_keys: Vec<String>,
163 /// External OIDC issuers trusted to mint JWTs the engine accepts
164 /// pass-through (v0.3.2). Each entry's JWKS is discovered via
165 /// `<issuer_url>/.well-known/openid-configuration` at boot and
166 /// refreshed periodically thereafter. Tokens whose `iss` claim
167 /// matches a configured issuer are verified against that issuer's
168 /// keys; everything else falls through to the engine's internal
169 /// JWT path. When this list is non-empty, the engine boots without
170 /// requiring operator users / `admin_api_keys` — the upstream IdP
171 /// is the source of truth for identity.
172 ///
173 /// Mirrors the v0.12.1 `--auth-issuer` / `--auth-audience` CLI
174 /// flags in the new TOML config shape. Multiple issuers are allowed
175 /// for deployments that span more than one IdP.
176 ///
177 /// Field is private so future entries (per-issuer policy, claim
178 /// mappers, etc.) can be added without breaking downstream
179 /// construction. Read via [`AuthConfig::external_issuers`].
180 #[serde(default)]
181 external_issuers: Vec<ExternalIssuerConfig>,
182}
183
184impl AuthConfig {
185 /// Read access to the parsed `[[auth.external_issuers]]` blocks.
186 pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
187 &self.external_issuers
188 }
189}
190
191/// One trusted external OIDC issuer for pass-through JWT validation.
192#[derive(Clone, Debug, Default, Deserialize, Serialize)]
193#[non_exhaustive]
194pub struct ExternalIssuerConfig {
195 /// Issuer URL — the value the JWT's `iss` claim is matched against
196 /// and the base for `<issuer_url>/.well-known/openid-configuration`
197 /// discovery. Trailing slashes are normalized.
198 pub issuer_url: String,
199 /// Accepted `aud` claim values. A token whose `aud` isn't in this
200 /// list is rejected. Empty list = audience check disabled (NOT
201 /// recommended; set explicitly per deployment).
202 #[serde(default)]
203 pub audience: Vec<String>,
204 /// JWKS refresh interval in seconds (background task). Default 3600
205 /// (1 hour). Minimum effective value 60 seconds — anything smaller
206 /// is clamped to avoid hammering the upstream's JWKS endpoint.
207 #[serde(default = "default_jwks_refresh_secs")]
208 pub jwks_refresh_secs: u64,
209}
210
211fn default_jwks_refresh_secs() -> u64 {
212 3600
213}
214
215/// Session module knobs.
216#[derive(Clone, Debug, Default, Deserialize, Serialize)]
217#[non_exhaustive]
218pub struct AuthSessionConfig {
219 /// Default session lifetime in seconds. `None` ⇒ uses the
220 /// `assay_auth::session::DEFAULT_SESSION_DURATION` (30 days).
221 pub ttl_seconds: Option<u64>,
222}
223
224/// WebAuthn / passkey module knobs.
225#[derive(Clone, Debug, Default, Deserialize, Serialize)]
226#[non_exhaustive]
227pub struct AuthPasskeyConfig {
228 /// Relying-party id — the host (no scheme/port) the browser will
229 /// scope passkeys to. Defaults to the host of `auth.public_url`, or
230 /// `server.public_url` when no dedicated auth origin is configured.
231 pub rp_id: Option<String>,
232 /// Human-readable label browsers show. Defaults to `"Assay"`.
233 pub rp_name: Option<String>,
234}
235
236/// OIDC provider knobs.
237#[derive(Clone, Debug, Default, Deserialize, Serialize)]
238#[non_exhaustive]
239pub struct AuthOidcProviderConfig {
240 /// Whether the OIDC provider routes (/authorize /token /userinfo …)
241 /// are mounted. Defaults to `true` when the Cargo feature is on.
242 #[serde(default = "default_true")]
243 pub enabled: bool,
244 /// Override the issuer URL used by the OIDC provider. Defaults to
245 /// the parent [`AuthConfig::issuer`] when unset.
246 pub issuer_override: Option<String>,
247 /// `true` → federation callback creates an `auth.users` row on
248 /// first sign-in for a new upstream identity (open
249 /// signup; legacy library default — kept as the default
250 /// here so omitted config does not silently flip
251 /// existing deployments into invite-only).
252 /// `false` → callback looks up by email (and requires the
253 /// upstream `email_verified` claim); missing rows
254 /// return 403. Operators pre-populate `auth.users` via
255 /// the admin API or the sysops `/auth/users` page.
256 /// Recommended for shared / multi-tenant deployments —
257 /// must be set explicitly.
258 #[serde(default = "default_true")]
259 pub auto_provision: bool,
260}
261
262#[derive(Clone, Debug, Deserialize, Serialize)]
263#[non_exhaustive]
264pub struct DashboardConfig {
265 #[serde(default = "default_true")]
266 pub enabled: bool,
267}
268
269impl Default for DashboardConfig {
270 fn default() -> Self {
271 // When the `[dashboard]` section is omitted entirely from
272 // engine.toml, serde calls Default::default() — and bool's
273 // derived default is `false`. We want `enabled: true` here so
274 // a fresh engine.toml without a [dashboard] section still
275 // mounts the SPAs out of the box.
276 Self { enabled: true }
277 }
278}
279
280#[derive(Clone, Debug, Deserialize, Serialize)]
281#[non_exhaustive]
282pub struct LoggingConfig {
283 #[serde(default = "default_log_level")]
284 pub level: String,
285 #[serde(default = "default_log_format")]
286 pub format: String,
287}
288
289impl Default for LoggingConfig {
290 fn default() -> Self {
291 Self {
292 level: default_log_level(),
293 format: default_log_format(),
294 }
295 }
296}
297
298fn default_true() -> bool {
299 true
300}
301
302fn default_log_level() -> String {
303 "info".to_string()
304}
305
306fn default_log_format() -> String {
307 "pretty".to_string()
308}
309
310impl EngineConfig {
311 /// Load `engine.toml`. String fields support `${VAR}` and
312 /// `${VAR:-default}` env-var references; references with no default
313 /// error out at load time when the variable is unset. Bracket-less
314 /// `$VAR` is left untouched, and `${...}` whose contents aren't a
315 /// valid identifier are passed through verbatim.
316 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
317 let raw = std::fs::read_to_string(path)
318 .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
319 let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
320 .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
321 let cfg: Self = toml::from_str(&expanded)
322 .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
323 Ok(cfg)
324 }
325}
326
327/// Expand `${VAR}` and `${VAR:-default}` references in `raw` using
328/// `lookup` to resolve names. The lookup-by-closure shape keeps this
329/// pure for unit tests (the binary path uses `std::env::var`).
330///
331/// Behavior:
332/// - `${VAR}` → value if set, error if unset.
333/// - `${VAR:-default}` → value if set, else the default (which may be empty).
334/// - Bracket-less `$VAR` is untouched.
335/// - `${...}` whose contents aren't a valid identifier are passed
336/// through verbatim — keeps non-substitution `${...}` literals usable
337/// in odd field values without false positives.
338fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
339where
340 F: Fn(&str) -> Option<String>,
341{
342 let mut out = String::with_capacity(raw.len());
343 let mut rest = raw;
344 while let Some(idx) = rest.find("${") {
345 out.push_str(&rest[..idx]);
346 let after_open = &rest[idx + 2..];
347 let close_idx = after_open
348 .find('}')
349 .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
350 let inner = &after_open[..close_idx];
351 let (var_name, default) = match inner.split_once(":-") {
352 Some((n, d)) => (n, Some(d)),
353 None => (inner, None),
354 };
355 if !is_valid_var_name(var_name) {
356 // Not a valid identifier — pass the whole `${...}` through.
357 out.push_str("${");
358 out.push_str(inner);
359 out.push('}');
360 } else {
361 match lookup(var_name) {
362 Some(val) => out.push_str(&val),
363 None => match default {
364 Some(def) => out.push_str(def),
365 None => {
366 return Err(anyhow::anyhow!(
367 "env var `{}` is not set and has no default",
368 var_name
369 ));
370 }
371 },
372 }
373 }
374 rest = &after_open[close_idx + 1..];
375 }
376 out.push_str(rest);
377 Ok(out)
378}
379
380fn is_valid_var_name(s: &str) -> bool {
381 let mut chars = s.chars();
382 match chars.next() {
383 Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
384 _ => return false,
385 }
386 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
394 move |name: &str| {
395 map.iter()
396 .find(|(k, _)| *k == name)
397 .map(|(_, v)| (*v).to_string())
398 }
399 }
400
401 #[test]
402 fn no_substitution_passes_through() {
403 let s = "plain string with $literal but no expansion markers";
404 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
405 }
406
407 #[test]
408 fn substitutes_set_var() {
409 let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
410 assert_eq!(out, "value=hello");
411 }
412
413 #[test]
414 fn errors_on_unset_var_with_no_default() {
415 let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
416 assert!(err.to_string().contains("MISSING"));
417 }
418
419 #[test]
420 fn falls_back_to_default_when_unset() {
421 let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
422 assert_eq!(out, "fallback");
423 }
424
425 #[test]
426 fn ignores_default_when_var_set() {
427 let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
428 assert_eq!(out, "actual");
429 }
430
431 #[test]
432 fn empty_default_yields_empty_string() {
433 let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
434 assert_eq!(out, "[]");
435 }
436
437 #[test]
438 fn substitutes_multiple_vars_in_one_string() {
439 let out = expand_env_vars(
440 "postgres://u:p@${HOST}:${PORT}/x",
441 lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
442 )
443 .unwrap();
444 assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
445 }
446
447 #[test]
448 fn dollar_without_braces_passes_through() {
449 // Bracket-less `$IDENT` is intentionally left alone — only the
450 // `${...}` form is treated as an env reference.
451 let s = "$HOME and $USER stay literal";
452 let out = expand_env_vars(s, lookup_from(&[])).unwrap();
453 assert_eq!(out, s);
454 }
455
456 #[test]
457 fn invalid_identifier_passes_through_verbatim() {
458 // Digit-leading is not a valid identifier; `${1NOT_VALID}` stays literal.
459 let s = "${1NOT_VALID}";
460 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
461 }
462
463 #[test]
464 fn unclosed_brace_errors() {
465 let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
466 assert!(err.to_string().contains("unclosed"));
467 }
468
469 #[test]
470 fn substitutes_inside_toml_string_values() {
471 let toml_input = r#"
472[backend]
473type = "postgres"
474url = "${DB}"
475"#;
476 let expanded =
477 expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
478 assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
479 }
480
481 #[test]
482 fn is_valid_var_name_accepts_typical_names() {
483 assert!(is_valid_var_name("DATABASE_URL"));
484 assert!(is_valid_var_name("_PRIVATE"));
485 assert!(is_valid_var_name("X"));
486 assert!(is_valid_var_name("X1"));
487 }
488
489 #[test]
490 fn is_valid_var_name_rejects_bad_names() {
491 assert!(!is_valid_var_name(""));
492 assert!(!is_valid_var_name("1LEADING_DIGIT"));
493 assert!(!is_valid_var_name("HAS SPACE"));
494 assert!(!is_valid_var_name("HAS-DASH"));
495 assert!(!is_valid_var_name("HAS.DOT"));
496 }
497
498 #[test]
499 fn from_file_loads_static_toml() {
500 // Integration sanity that the from_file path still works after the
501 // expansion step is wired in. Uses a config with no env-var
502 // references to keep the test hermetic.
503 let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
504 std::fs::write(
505 &path,
506 r#"
507[server]
508bind_addr = "127.0.0.1:3000"
509
510[backend]
511type = "sqlite"
512data_dir = "/tmp/assay-engine-test-data-static"
513"#,
514 )
515 .unwrap();
516 let cfg = EngineConfig::from_file(&path).unwrap();
517 let _ = std::fs::remove_file(&path);
518 match cfg.backend {
519 BackendConfig::Sqlite { ref data_dir, .. } => {
520 assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
521 }
522 _ => panic!("expected sqlite backend"),
523 }
524 }
525}