Skip to main content

gwk_kernel/
config.rs

1//! Process configuration, read from the environment.
2//!
3//! The two DSNs are deliberately not interchangeable. `GWK_ADMIN_DATABASE_URL`
4//! owns the schema and exists only for the one-shot `admin init`;
5//! `GWK_DATABASE_URL` is the least-privilege runtime credential the daemon
6//! receives. A daemon that can reach the admin DSN can re-DDL its own store, so
7//! [`KernelConfig::from_env`] REFUSES to start when the admin variable is
8//! present rather than trusting itself to ignore it. Putting both in one unit
9//! file is the mistake this catches.
10
11use std::path::{Path, PathBuf};
12
13use base64::prelude::{BASE64_STANDARD, Engine as _};
14use secrecy::{SecretBox, SecretString};
15
16use crate::blob::container::DEK_BYTES;
17use crate::error::{KernelError, Result};
18
19/// The runtime (least-privilege) connection string.
20pub const DATABASE_URL_ENV: &str = "GWK_DATABASE_URL";
21/// The schema-owner connection string. One-shot initialization only.
22pub const ADMIN_DATABASE_URL_ENV: &str = "GWK_ADMIN_DATABASE_URL";
23/// The already-created role the runtime credential authenticates as.
24pub const RUNTIME_ROLE_ENV: &str = "GWK_RUNTIME_ROLE";
25/// Where the daemon binds its Unix domain socket.
26pub const SOCKET_PATH_ENV: &str = "GWK_SOCKET_PATH";
27
28/// Where blob containers are written. Required, absolute, no default.
29pub const BLOB_ROOT_ENV: &str = "GWK_BLOB_ROOT";
30/// The key-encryption key, base64 over exactly [`DEK_BYTES`] bytes.
31pub const BLOB_KEK_ENV: &str = "GWK_BLOB_KEK";
32/// The nonsecret label recorded beside every blob this KEK wraps.
33pub const BLOB_KEK_ID_ENV: &str = "GWK_BLOB_KEK_ID";
34
35/// The default socket path (ADR 0002: UDS only, no network listener).
36pub const DEFAULT_SOCKET_PATH: &str = "/run/gridwork/gwk.sock";
37
38/// Longest legal PostgreSQL identifier — `NAMEDATALEN - 1`.
39pub const MAX_IDENTIFIER_BYTES: usize = 63;
40
41/// Longest legal KEK label. It is copied into every container header, so it is
42/// kept short on purpose — this is a name, not a place to stash material.
43pub const MAX_KEK_ID_BYTES: usize = 64;
44
45/// The daemon's configuration.
46///
47/// `Debug` is safe to log: the DSN is a [`SecretString`], which redacts itself.
48#[derive(Debug)]
49pub struct KernelConfig {
50    database_url: SecretString,
51    socket_path: PathBuf,
52}
53
54/// The one-shot initializer's configuration.
55#[derive(Debug)]
56pub struct AdminConfig {
57    admin_database_url: SecretString,
58    runtime_role: String,
59}
60
61impl KernelConfig {
62    pub fn from_env() -> Result<Self> {
63        Self::from_lookup(env_lookup)
64    }
65
66    /// The env-independent half. Tests drive the rules through this instead of
67    /// `set_var`, which is unsafe in edition 2024 and races parallel tests.
68    pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
69        if get(ADMIN_DATABASE_URL_ENV).is_some() {
70            return Err(KernelError::Config(format!(
71                "{ADMIN_DATABASE_URL_ENV} is set: the schema-owner credential is for one-shot \
72                 `gw admin init` only and must never reach the daemon's environment"
73            )));
74        }
75        let database_url = database_url(&get, DATABASE_URL_ENV)?;
76        let socket_path = get(SOCKET_PATH_ENV)
77            .map(PathBuf::from)
78            .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_PATH));
79        if socket_path.as_os_str().is_empty() {
80            return Err(KernelError::Config(format!("{SOCKET_PATH_ENV} is empty")));
81        }
82        Ok(Self {
83            database_url,
84            socket_path,
85        })
86    }
87
88    pub fn database_url(&self) -> &SecretString {
89        &self.database_url
90    }
91
92    pub fn socket_path(&self) -> &Path {
93        &self.socket_path
94    }
95}
96
97impl AdminConfig {
98    pub fn from_env() -> Result<Self> {
99        Self::from_lookup(env_lookup)
100    }
101
102    pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
103        let admin_database_url = database_url(&get, ADMIN_DATABASE_URL_ENV)?;
104        let runtime_role = get(RUNTIME_ROLE_ENV).ok_or_else(|| {
105            KernelError::Config(format!(
106                "{RUNTIME_ROLE_ENV} is not set: initialization grants an ALREADY-CREATED runtime \
107                 role and never creates one"
108            ))
109        })?;
110        validate_role(&runtime_role)?;
111        Ok(Self {
112            admin_database_url,
113            runtime_role,
114        })
115    }
116
117    pub fn admin_database_url(&self) -> &SecretString {
118        &self.admin_database_url
119    }
120
121    /// The runtime role, already validated as a bare lowercase identifier.
122    pub fn runtime_role(&self) -> &str {
123        &self.runtime_role
124    }
125}
126
127/// The blob spine's configuration.
128///
129/// All three variables are required and none has a default. A default root
130/// would put ciphertext somewhere nobody chose, and a default KEK would be a
131/// key everyone shares — the failure mode being avoided is a deployment that
132/// starts successfully while storing blobs it cannot protect.
133///
134/// `Debug` is safe to log: the KEK is a [`SecretBox`], which redacts itself.
135/// The label beside it is deliberately NOT secret, because it has to travel in
136/// the clear inside every container header.
137#[derive(Debug)]
138pub struct BlobConfig {
139    root: PathBuf,
140    kek: SecretBox<[u8; DEK_BYTES]>,
141    kek_id: String,
142}
143
144impl BlobConfig {
145    pub fn from_env() -> Result<Self> {
146        Self::from_lookup(env_lookup)
147    }
148
149    pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
150        let raw_root = get(BLOB_ROOT_ENV)
151            .ok_or_else(|| KernelError::Config(format!("{BLOB_ROOT_ENV} is not set")))?;
152        let root = PathBuf::from(raw_root.trim());
153        if root.as_os_str().is_empty() {
154            return Err(KernelError::Config(format!("{BLOB_ROOT_ENV} is empty")));
155        }
156        // Absolute only. A relative root resolves against whatever directory
157        // the process happened to start in, so the same unit file would store
158        // blobs in two places and find them in neither.
159        if !root.is_absolute() {
160            return Err(KernelError::Config(format!(
161                "{BLOB_ROOT_ENV} must be an absolute path, got {root:?}"
162            )));
163        }
164
165        let encoded = get(BLOB_KEK_ENV)
166            .ok_or_else(|| KernelError::Config(format!("{BLOB_KEK_ENV} is not set")))?;
167        let decoded = BASE64_STANDARD
168            .decode(encoded.trim())
169            // The error is not included: it reports positions and lengths of
170            // the value being parsed, and that value is a key.
171            .map_err(|_| KernelError::Config(format!("{BLOB_KEK_ENV} is not valid base64")))?;
172        let mut kek = Box::new([0u8; DEK_BYTES]);
173        if decoded.len() != DEK_BYTES {
174            return Err(KernelError::Config(format!(
175                "{BLOB_KEK_ENV} decodes to {} bytes, expected exactly {DEK_BYTES}",
176                decoded.len()
177            )));
178        }
179        kek.copy_from_slice(&decoded);
180
181        let kek_id = get(BLOB_KEK_ID_ENV)
182            .ok_or_else(|| KernelError::Config(format!("{BLOB_KEK_ID_ENV} is not set")))?;
183        validate_kek_id(&kek_id)?;
184
185        Ok(Self {
186            root,
187            kek: SecretBox::new(kek),
188            kek_id,
189        })
190    }
191
192    /// Build a config directly, for tests and for a caller that already holds
193    /// the key material.
194    pub fn new(root: PathBuf, kek: [u8; DEK_BYTES], kek_id: String) -> Result<Self> {
195        validate_kek_id(&kek_id)?;
196        Ok(Self {
197            root,
198            kek: SecretBox::new(Box::new(kek)),
199            kek_id,
200        })
201    }
202
203    pub fn root(&self) -> &Path {
204        &self.root
205    }
206
207    pub fn kek(&self) -> &SecretBox<[u8; DEK_BYTES]> {
208        &self.kek
209    }
210
211    pub fn kek_id(&self) -> &str {
212        &self.kek_id
213    }
214}
215
216/// The label is written into every container header and is what a rotation
217/// matches on, so it stays a plain short name: no separators to confuse a
218/// parser, nothing that could carry material, nothing that changes meaning
219/// under a different locale.
220pub fn validate_kek_id(kek_id: &str) -> Result<()> {
221    let invalid = |why: &str| {
222        Err(KernelError::Config(format!(
223            "{BLOB_KEK_ID_ENV} {why}: expected 1..={MAX_KEK_ID_BYTES} bytes matching \
224             [A-Za-z0-9._-], got {kek_id:?}"
225        )))
226    };
227    if kek_id.is_empty() {
228        return invalid("is empty");
229    }
230    if kek_id.len() > MAX_KEK_ID_BYTES {
231        return invalid("is too long");
232    }
233    if !kek_id
234        .bytes()
235        .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
236    {
237        return invalid("contains a character outside [A-Za-z0-9._-]");
238    }
239    Ok(())
240}
241
242fn env_lookup(key: &str) -> Option<String> {
243    std::env::var(key).ok()
244}
245
246/// A connection string must announce itself as PostgreSQL. Without this, a
247/// swapped variable reaches the driver as an opaque parse error at connect
248/// time instead of a named one at startup.
249fn database_url(get: &impl Fn(&str) -> Option<String>, key: &str) -> Result<SecretString> {
250    let raw = get(key).ok_or_else(|| KernelError::Config(format!("{key} is not set")))?;
251    let trimmed = raw.trim();
252    if trimmed.is_empty() {
253        return Err(KernelError::Config(format!("{key} is empty")));
254    }
255    if !trimmed.starts_with("postgres://") && !trimmed.starts_with("postgresql://") {
256        return Err(KernelError::Config(format!(
257            "{key} is not a PostgreSQL URL (expected a postgres:// or postgresql:// scheme)"
258        )));
259    }
260    Ok(SecretString::from(trimmed.to_owned()))
261}
262
263/// PostgreSQL cannot bind an identifier as a parameter, so the role name is
264/// interpolated into the GRANT script. This allowlist is what keeps that safe:
265/// a bare lowercase identifier needs no quoting and cannot carry a statement
266/// separator, a quote, or a comment.
267pub fn validate_role(role: &str) -> Result<()> {
268    let invalid = |why: &str| {
269        Err(KernelError::Config(format!(
270            "{RUNTIME_ROLE_ENV} {why}: expected a bare lowercase identifier matching \
271             [a-z_][a-z0-9_]* of at most {MAX_IDENTIFIER_BYTES} bytes, got {role:?}"
272        )))
273    };
274    if role.is_empty() {
275        return invalid("is empty");
276    }
277    if role.len() > MAX_IDENTIFIER_BYTES {
278        return invalid("is too long");
279    }
280    let mut bytes = role.bytes();
281    let first = bytes.next().unwrap_or(b'0');
282    if !(first.is_ascii_lowercase() || first == b'_') {
283        return invalid("does not start with a lowercase letter or underscore");
284    }
285    if !bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
286        return invalid("contains a character outside [a-z0-9_]");
287    }
288    Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293    use secrecy::ExposeSecret;
294
295    use super::*;
296
297    fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
298        let owned: Vec<(String, String)> = pairs
299            .iter()
300            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
301            .collect();
302        move |key| {
303            owned
304                .iter()
305                .find(|(k, _)| k == key)
306                .map(|(_, v)| v.to_owned())
307        }
308    }
309
310    const DSN: &str = "postgres://gwk@/gridwork";
311    const ADMIN_DSN: &str = "postgres://owner@/gridwork";
312
313    #[test]
314    fn the_daemon_refuses_to_start_beside_the_admin_credential() {
315        let err = KernelConfig::from_lookup(lookup(&[
316            (DATABASE_URL_ENV, DSN),
317            (ADMIN_DATABASE_URL_ENV, ADMIN_DSN),
318        ]))
319        .expect_err("both DSNs present must refuse");
320        assert!(
321            err.to_string().contains(ADMIN_DATABASE_URL_ENV),
322            "the error must name the offending variable: {err}"
323        );
324    }
325
326    #[test]
327    fn the_daemon_defaults_its_socket_and_keeps_its_dsn() {
328        let cfg = KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, DSN)])).expect("config");
329        assert_eq!(cfg.socket_path(), Path::new(DEFAULT_SOCKET_PATH));
330        assert_eq!(cfg.database_url().expose_secret(), DSN);
331
332        let cfg = KernelConfig::from_lookup(lookup(&[
333            (DATABASE_URL_ENV, DSN),
334            (SOCKET_PATH_ENV, "/tmp/gwk.sock"),
335        ]))
336        .expect("config");
337        assert_eq!(cfg.socket_path(), Path::new("/tmp/gwk.sock"));
338    }
339
340    #[test]
341    fn a_missing_or_non_postgres_dsn_is_named_at_startup() {
342        let err = KernelConfig::from_lookup(lookup(&[])).expect_err("missing DSN");
343        assert!(err.to_string().contains(DATABASE_URL_ENV), "{err}");
344
345        for bad in ["", "   ", "mysql://x/y", "/var/run/postgres", "gridwork"] {
346            let err = KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, bad)]))
347                .expect_err("non-postgres DSN must refuse");
348            assert!(err.to_string().contains(DATABASE_URL_ENV), "{bad:?}: {err}");
349        }
350        // Both spellings the driver accepts.
351        for good in ["postgres://gwk@/db", "postgresql://gwk@/db"] {
352            KernelConfig::from_lookup(lookup(&[(DATABASE_URL_ENV, good)])).expect(good);
353        }
354    }
355
356    #[test]
357    fn the_admin_needs_a_role_to_grant() {
358        let err = AdminConfig::from_lookup(lookup(&[(ADMIN_DATABASE_URL_ENV, ADMIN_DSN)]))
359            .expect_err("missing role");
360        assert!(err.to_string().contains(RUNTIME_ROLE_ENV), "{err}");
361
362        let cfg = AdminConfig::from_lookup(lookup(&[
363            (ADMIN_DATABASE_URL_ENV, ADMIN_DSN),
364            (RUNTIME_ROLE_ENV, "gwk_runtime"),
365        ]))
366        .expect("config");
367        assert_eq!(cfg.runtime_role(), "gwk_runtime");
368        assert_eq!(cfg.admin_database_url().expose_secret(), ADMIN_DSN);
369    }
370
371    /// A legal KEK: 32 bytes, base64. Not a real one — it is `[7; 32]`, which
372    /// no deployment would ever hold.
373    fn kek_b64() -> String {
374        BASE64_STANDARD.encode([7u8; DEK_BYTES])
375    }
376
377    #[test]
378    fn the_blob_spine_refuses_to_start_without_a_root_a_key_and_a_label() {
379        let full = |root: &str| {
380            vec![
381                (BLOB_ROOT_ENV, root.to_owned()),
382                (BLOB_KEK_ENV, kek_b64()),
383                (BLOB_KEK_ID_ENV, "kek-2026-07".to_owned()),
384            ]
385        };
386        let lookup_owned = |pairs: Vec<(&str, String)>| {
387            let owned: Vec<(String, String)> =
388                pairs.into_iter().map(|(k, v)| (k.to_owned(), v)).collect();
389            move |key: &str| {
390                owned
391                    .iter()
392                    .find(|(k, _)| k == key)
393                    .map(|(_, v)| v.to_owned())
394            }
395        };
396
397        let cfg =
398            BlobConfig::from_lookup(lookup_owned(full("/var/lib/gridwork/blobs"))).expect("config");
399        assert_eq!(cfg.root(), Path::new("/var/lib/gridwork/blobs"));
400        assert_eq!(cfg.kek_id(), "kek-2026-07");
401        assert_eq!(cfg.kek().expose_secret(), &[7u8; DEK_BYTES]);
402
403        // Each variable is required on its own: a deployment missing one must
404        // be told which, not handed a default that silently stores blobs it
405        // cannot protect or cannot find again.
406        for missing in [BLOB_ROOT_ENV, BLOB_KEK_ENV, BLOB_KEK_ID_ENV] {
407            let pairs: Vec<_> = full("/var/lib/gridwork/blobs")
408                .into_iter()
409                .filter(|(k, _)| *k != missing)
410                .collect();
411            let err = BlobConfig::from_lookup(lookup_owned(pairs))
412                .expect_err(&format!("{missing} must be required"));
413            assert!(err.to_string().contains(missing), "{err}");
414        }
415
416        // A relative root resolves against whatever directory the process
417        // started in, which is not a place anybody chose.
418        for bad_root in ["", "   ", "blobs", "./blobs", "../blobs"] {
419            BlobConfig::from_lookup(lookup_owned(full(bad_root)))
420                .expect_err(&format!("{bad_root:?} must be refused"));
421        }
422    }
423
424    #[test]
425    fn the_kek_must_decode_to_exactly_one_key() {
426        let with_kek = |value: &str| {
427            let owned = value.to_owned();
428            move |key: &str| match key {
429                BLOB_ROOT_ENV => Some("/var/lib/gridwork/blobs".to_owned()),
430                BLOB_KEK_ENV => Some(owned.clone()),
431                BLOB_KEK_ID_ENV => Some("kek-2026-07".to_owned()),
432                _ => None,
433            }
434        };
435        BlobConfig::from_lookup(with_kek(&kek_b64())).expect("32 bytes");
436        // Whitespace around a value pasted out of a secret manager.
437        BlobConfig::from_lookup(with_kek(&format!(" {}\n", kek_b64()))).expect("trimmed");
438
439        for (why, value) in [
440            ("not base64", "not base64 at all!".to_owned()),
441            ("too short", BASE64_STANDARD.encode([7u8; DEK_BYTES - 1])),
442            ("too long", BASE64_STANDARD.encode([7u8; DEK_BYTES + 1])),
443            ("empty", String::new()),
444        ] {
445            let err = BlobConfig::from_lookup(with_kek(&value))
446                .expect_err(&format!("{why} must be refused"));
447            let message = err.to_string();
448            assert!(message.contains(BLOB_KEK_ENV), "{why}: {message}");
449            // The variable is named; its VALUE never is. A base64 error reports
450            // offsets and lengths of the thing being decoded, and that thing is
451            // a key.
452            assert!(
453                !message.contains(&value) || value.is_empty(),
454                "{why}: {message}"
455            );
456        }
457    }
458
459    #[test]
460    fn the_kek_label_stays_a_plain_short_name() {
461        for good in ["k", "kek-2026-07", "prod.blob_kek", &"a".repeat(64)] {
462            validate_kek_id(good).unwrap_or_else(|e| panic!("{good:?} should be legal: {e}"));
463        }
464        // Every rejection below would be copied verbatim into the header of
465        // every container this key wraps.
466        for bad in [
467            "",
468            "with space",
469            "with/slash",
470            "with\0null",
471            "with\nnewline",
472            "émoji",
473            &"a".repeat(65),
474        ] {
475            validate_kek_id(bad).expect_err(&format!("{bad:?} must be refused"));
476        }
477    }
478
479    #[test]
480    fn only_a_bare_lowercase_identifier_reaches_the_grant_script() {
481        for good in ["gwk_runtime", "_x", "r0", "a".repeat(63).as_str()] {
482            validate_role(good).unwrap_or_else(|e| panic!("{good:?} should be legal: {e}"));
483        }
484        // Every rejection below would otherwise be interpolated into a GRANT.
485        for bad in [
486            "",
487            "0leading",
488            "Upper",
489            "with-dash",
490            "with space",
491            "quote\"d",
492            "semi;colon",
493            "dash--comment",
494            "role; DROP SCHEMA gwk CASCADE",
495            "a".repeat(64).as_str(),
496        ] {
497            validate_role(bad).expect_err(&format!("{bad:?} must be refused"));
498        }
499    }
500}