Skip to main content

systemprompt_config/bootstrap/secrets/
mod.rs

1//! Process-wide secrets bootstrap.
2//!
3//! Loads the secrets document referenced by the active profile, validates
4//! required fields, and exposes typed accessors for the manifest signing seed
5//! and database URLs.
6//!
7//! Source precedence, highest first:
8//!
9//! | # | Condition | Source |
10//! |---|-----------|--------|
11//! | 1 | `SYSTEMPROMPT_SUBPROCESS` set and a valid pepper is in the environment | environment |
12//! | 2 | `secrets.source: vault` | Vault KV v2, on deployment hosts too |
13//! | 3 | deployment host, with a valid pepper or `secrets.source: env` | environment |
14//! | 4 | `secrets.source: env` running locally | file, then environment |
15//! | 5 | `secrets.source: file` | file |
16//!
17//! Vault is fail-closed under every
18//! [`systemprompt_models::profile::SecretsValidationMode`]: a failed fetch
19//! aborts the boot instead of falling back to the environment,
20//! because a fallback would start the process on whatever stale credentials the
21//! host happens to carry.
22//!
23//! A Vault token is never written into profile YAML — `${VAULT_TOKEN}`
24//! interpolation in `profile.yaml` is forbidden. The token comes from the
25//! process environment, a file, or an `AppRole` / Kubernetes login.
26//!
27//! Copyright (c) systemprompt.io — Business Source License 1.1.
28//! See <https://systemprompt.io> for licensing details.
29
30mod io;
31mod loader;
32mod logging;
33mod provider;
34mod resolve;
35mod sources;
36mod vault;
37
38use std::path::{Path, PathBuf};
39use std::sync::OnceLock;
40
41use base64::Engine;
42use systemprompt_models::profile::resolve_with_home;
43use systemprompt_models::secrets::Secrets;
44
45use super::manifest::{MANIFEST_SIGNING_SEED_BYTES, decode_seed, generate_seed, persist_seed};
46use super::profile::ProfileBootstrap;
47use crate::error::{ConfigError, ConfigResult};
48
49pub use io::load_secrets_from_path;
50pub use logging::{
51    build_loaded_secrets_message, log_secrets_issue, log_secrets_skip, log_secrets_warn,
52};
53pub use provider::{SecretsDocument, SecretsProvider};
54pub use resolve::{ResolvedSource, resolve_source};
55pub use vault::{VaultError, VaultKvProvider};
56
57static SECRETS: OnceLock<Secrets> = OnceLock::new();
58
59#[derive(Debug, Clone, Copy)]
60pub struct SecretsBootstrap;
61
62#[derive(Debug, thiserror::Error)]
63#[non_exhaustive]
64pub enum SecretsBootstrapError {
65    #[error(
66        "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
67    )]
68    NotInitialized,
69
70    #[error("Secrets already initialized")]
71    AlreadyInitialized,
72
73    #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
74    ProfileNotInitialized,
75
76    #[error("Secrets file not found: {path}")]
77    FileNotFound { path: String },
78
79    #[error("Invalid secrets file: {message}")]
80    InvalidSecretsFile { message: String },
81
82    #[error("No secrets configured. Create a secrets.json file.")]
83    NoSecretsConfigured,
84
85    #[error("Invalid secrets configuration in profile: {message}")]
86    SecretsConfigInvalid { message: String },
87
88    #[error(
89        "secrets.source is 'vault' but the profile has no secrets.vault block. Add one or switch \
90         secrets.source to 'file' or 'env'."
91    )]
92    VaultBlockMissing,
93
94    #[error(transparent)]
95    Vault(#[from] VaultError),
96
97    #[error(
98        "OAuth at-rest pepper is required. Add 'oauth_at_rest_pepper' (>= 32 chars) to your \
99         secrets file or set OAUTH_AT_REST_PEPPER environment variable."
100    )]
101    OauthAtRestPepperRequired,
102
103    #[error(
104        "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
105         environment variable."
106    )]
107    DatabaseUrlRequired,
108
109    #[error(
110        "manifest_signing_secret_seed is required: every replica must share one seed, so it is \
111         never generated at boot. Run `systemprompt admin identity generate --json` once and \
112         distribute the value (secrets file or MANIFEST_SIGNING_SECRET_SEED)."
113    )]
114    ManifestSeedRequired,
115
116    #[error(
117        "signing_key_pem is required on cloud and deployment-host boots: every replica must \
118         sign with one key, so it is never read from a file beside the binary there. Run \
119         `systemprompt admin identity generate --json` once and distribute the value (secrets \
120         file or SIGNING_KEY_PEM)."
121    )]
122    SigningKeyPemRequired,
123
124    #[error("manifest_signing_secret_seed is invalid: {message}")]
125    ManifestSeedInvalid { message: String },
126
127    #[error("signing_key_pem secret is invalid: {message}")]
128    SigningKeyPemInvalid { message: String },
129}
130
131impl SecretsBootstrap {
132    pub async fn init() -> ConfigResult<&'static Secrets> {
133        if SECRETS.get().is_some() {
134            return Err(SecretsBootstrapError::AlreadyInitialized.into());
135        }
136
137        let secrets = loader::load_from_profile_config().await?;
138        Self::validate_identity(&secrets)?;
139
140        Self::log_loaded_secrets(&secrets);
141
142        SECRETS
143            .set(secrets)
144            .map_err(|_e| SecretsBootstrapError::AlreadyInitialized)?;
145
146        SECRETS
147            .get()
148            .ok_or_else(|| SecretsBootstrapError::NotInitialized.into())
149    }
150
151    pub fn oauth_at_rest_pepper() -> Result<&'static str, SecretsBootstrapError> {
152        Ok(&Self::get()?.oauth_at_rest_pepper)
153    }
154
155    pub fn signing_key_pem() -> Result<Option<String>, SecretsBootstrapError> {
156        let Some(encoded) = Self::get()?.signing_key_pem.as_deref() else {
157            return Ok(None);
158        };
159        let bytes = base64::engine::general_purpose::STANDARD
160            .decode(encoded)
161            .map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
162                message: e.to_string(),
163            })?;
164        let pem =
165            String::from_utf8(bytes).map_err(|e| SecretsBootstrapError::SigningKeyPemInvalid {
166                message: e.to_string(),
167            })?;
168        Ok(Some(pem))
169    }
170
171    pub fn manifest_signing_secret_seed()
172    -> Result<[u8; MANIFEST_SIGNING_SEED_BYTES], SecretsBootstrapError> {
173        let encoded = Self::get()?
174            .manifest_signing_secret_seed
175            .as_deref()
176            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
177        decode_seed(encoded)
178    }
179
180    pub fn rotate_manifest_signing_seed() -> ConfigResult<[u8; MANIFEST_SIGNING_SEED_BYTES]> {
181        let path = Self::resolved_secrets_file_path()?;
182        let seed = generate_seed();
183        persist_seed(&path, &seed)?;
184        Ok(seed)
185    }
186
187    fn validate_identity(secrets: &Secrets) -> ConfigResult<()> {
188        let encoded = secrets
189            .manifest_signing_secret_seed
190            .as_deref()
191            .ok_or(SecretsBootstrapError::ManifestSeedRequired)?;
192        decode_seed(encoded)?;
193
194        let is_deployment_host =
195            systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
196        let is_cloud = ProfileBootstrap::get().is_ok_and(|profile| profile.target.is_cloud());
197        if (is_deployment_host || is_cloud) && secrets.signing_key_pem.is_none() {
198            return Err(SecretsBootstrapError::SigningKeyPemRequired.into());
199        }
200        Ok(())
201    }
202
203    fn resolved_secrets_file_path() -> ConfigResult<PathBuf> {
204        let profile =
205            ProfileBootstrap::get().map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
206        let secrets_config = profile
207            .secrets
208            .as_ref()
209            .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
210        let profile_path = ProfileBootstrap::get_path()
211            .map_err(|_e| SecretsBootstrapError::ProfileNotInitialized)?;
212        let profile_dir = Path::new(profile_path)
213            .parent()
214            .ok_or_else(|| ConfigError::other("Invalid profile path - no parent directory"))?;
215        let secrets_path = secrets_config.secrets_path().map_err(|e| {
216            SecretsBootstrapError::SecretsConfigInvalid {
217                message: e.to_string(),
218            }
219        })?;
220        Ok(resolve_with_home(profile_dir, secrets_path))
221    }
222
223    pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
224        Ok(&Self::get()?.database_url)
225    }
226
227    pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
228        Ok(Self::get()?.database_write_url.as_deref())
229    }
230
231    pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
232        SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
233    }
234
235    #[must_use]
236    pub fn is_initialized() -> bool {
237        SECRETS.get().is_some()
238    }
239
240    pub async fn try_init() -> ConfigResult<&'static Secrets> {
241        if SECRETS.get().is_some() {
242            return Self::get().map_err(Into::into);
243        }
244        Self::init().await
245    }
246
247    fn log_loaded_secrets(secrets: &Secrets) {
248        let message = build_loaded_secrets_message(secrets);
249        tracing::debug!("{message}");
250    }
251}