Skip to main content

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}
243
244#[derive(Clone, Debug, Default, Deserialize, Serialize)]
245#[non_exhaustive]
246pub struct DashboardConfig {
247    #[serde(default = "default_true")]
248    pub enabled: bool,
249}
250
251#[derive(Clone, Debug, Deserialize, Serialize)]
252#[non_exhaustive]
253pub struct LoggingConfig {
254    #[serde(default = "default_log_level")]
255    pub level: String,
256    #[serde(default = "default_log_format")]
257    pub format: String,
258}
259
260impl Default for LoggingConfig {
261    fn default() -> Self {
262        Self {
263            level: default_log_level(),
264            format: default_log_format(),
265        }
266    }
267}
268
269fn default_true() -> bool {
270    true
271}
272
273fn default_log_level() -> String {
274    "info".to_string()
275}
276
277fn default_log_format() -> String {
278    "pretty".to_string()
279}
280
281impl EngineConfig {
282    /// Load `engine.toml`. String fields support `${VAR}` and
283    /// `${VAR:-default}` env-var references; references with no default
284    /// error out at load time when the variable is unset. Bracket-less
285    /// `$VAR` is left untouched, and `${...}` whose contents aren't a
286    /// valid identifier are passed through verbatim.
287    pub fn from_file(path: &Path) -> anyhow::Result<Self> {
288        let raw = std::fs::read_to_string(path)
289            .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
290        let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
291            .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
292        let cfg: Self = toml::from_str(&expanded)
293            .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
294        Ok(cfg)
295    }
296}
297
298/// Expand `${VAR}` and `${VAR:-default}` references in `raw` using
299/// `lookup` to resolve names. The lookup-by-closure shape keeps this
300/// pure for unit tests (the binary path uses `std::env::var`).
301///
302/// Behavior:
303/// - `${VAR}` → value if set, error if unset.
304/// - `${VAR:-default}` → value if set, else the default (which may be empty).
305/// - Bracket-less `$VAR` is untouched.
306/// - `${...}` whose contents aren't a valid identifier are passed
307///   through verbatim — keeps non-substitution `${...}` literals usable
308///   in odd field values without false positives.
309fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
310where
311    F: Fn(&str) -> Option<String>,
312{
313    let mut out = String::with_capacity(raw.len());
314    let mut rest = raw;
315    while let Some(idx) = rest.find("${") {
316        out.push_str(&rest[..idx]);
317        let after_open = &rest[idx + 2..];
318        let close_idx = after_open
319            .find('}')
320            .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
321        let inner = &after_open[..close_idx];
322        let (var_name, default) = match inner.split_once(":-") {
323            Some((n, d)) => (n, Some(d)),
324            None => (inner, None),
325        };
326        if !is_valid_var_name(var_name) {
327            // Not a valid identifier — pass the whole `${...}` through.
328            out.push_str("${");
329            out.push_str(inner);
330            out.push('}');
331        } else {
332            match lookup(var_name) {
333                Some(val) => out.push_str(&val),
334                None => match default {
335                    Some(def) => out.push_str(def),
336                    None => {
337                        return Err(anyhow::anyhow!(
338                            "env var `{}` is not set and has no default",
339                            var_name
340                        ));
341                    }
342                },
343            }
344        }
345        rest = &after_open[close_idx + 1..];
346    }
347    out.push_str(rest);
348    Ok(out)
349}
350
351fn is_valid_var_name(s: &str) -> bool {
352    let mut chars = s.chars();
353    match chars.next() {
354        Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
355        _ => return false,
356    }
357    chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
365        move |name: &str| {
366            map.iter()
367                .find(|(k, _)| *k == name)
368                .map(|(_, v)| (*v).to_string())
369        }
370    }
371
372    #[test]
373    fn no_substitution_passes_through() {
374        let s = "plain string with $literal but no expansion markers";
375        assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
376    }
377
378    #[test]
379    fn substitutes_set_var() {
380        let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
381        assert_eq!(out, "value=hello");
382    }
383
384    #[test]
385    fn errors_on_unset_var_with_no_default() {
386        let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
387        assert!(err.to_string().contains("MISSING"));
388    }
389
390    #[test]
391    fn falls_back_to_default_when_unset() {
392        let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
393        assert_eq!(out, "fallback");
394    }
395
396    #[test]
397    fn ignores_default_when_var_set() {
398        let out =
399            expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
400        assert_eq!(out, "actual");
401    }
402
403    #[test]
404    fn empty_default_yields_empty_string() {
405        let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
406        assert_eq!(out, "[]");
407    }
408
409    #[test]
410    fn substitutes_multiple_vars_in_one_string() {
411        let out = expand_env_vars(
412            "postgres://u:p@${HOST}:${PORT}/x",
413            lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
414        )
415        .unwrap();
416        assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
417    }
418
419    #[test]
420    fn dollar_without_braces_passes_through() {
421        // Bracket-less `$IDENT` is intentionally left alone — only the
422        // `${...}` form is treated as an env reference.
423        let s = "$HOME and $USER stay literal";
424        let out = expand_env_vars(s, lookup_from(&[])).unwrap();
425        assert_eq!(out, s);
426    }
427
428    #[test]
429    fn invalid_identifier_passes_through_verbatim() {
430        // Digit-leading is not a valid identifier; `${1NOT_VALID}` stays literal.
431        let s = "${1NOT_VALID}";
432        assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
433    }
434
435    #[test]
436    fn unclosed_brace_errors() {
437        let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
438        assert!(err.to_string().contains("unclosed"));
439    }
440
441    #[test]
442    fn substitutes_inside_toml_string_values() {
443        let toml_input = r#"
444[backend]
445type = "postgres"
446url = "${DB}"
447"#;
448        let expanded =
449            expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
450        assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
451    }
452
453    #[test]
454    fn is_valid_var_name_accepts_typical_names() {
455        assert!(is_valid_var_name("DATABASE_URL"));
456        assert!(is_valid_var_name("_PRIVATE"));
457        assert!(is_valid_var_name("X"));
458        assert!(is_valid_var_name("X1"));
459    }
460
461    #[test]
462    fn is_valid_var_name_rejects_bad_names() {
463        assert!(!is_valid_var_name(""));
464        assert!(!is_valid_var_name("1LEADING_DIGIT"));
465        assert!(!is_valid_var_name("HAS SPACE"));
466        assert!(!is_valid_var_name("HAS-DASH"));
467        assert!(!is_valid_var_name("HAS.DOT"));
468    }
469
470    #[test]
471    fn from_file_loads_static_toml() {
472        // Integration sanity that the from_file path still works after the
473        // expansion step is wired in. Uses a config with no env-var
474        // references to keep the test hermetic.
475        let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
476        std::fs::write(
477            &path,
478            r#"
479[server]
480bind_addr = "127.0.0.1:3000"
481
482[backend]
483type = "sqlite"
484data_dir = "/tmp/assay-engine-test-data-static"
485"#,
486        )
487        .unwrap();
488        let cfg = EngineConfig::from_file(&path).unwrap();
489        let _ = std::fs::remove_file(&path);
490        match cfg.backend {
491            BackendConfig::Sqlite { ref data_dir, .. } => {
492                assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
493            }
494            _ => panic!("expected sqlite backend"),
495        }
496    }
497}