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 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}