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