Skip to main content

systemprompt_cli/commands/admin/config/
secret_check.rs

1//! `admin config secret check` — report where secrets come from and which
2//! keys are present.
3//!
4//! The command prints key *names* only. A value never reaches the terminal,
5//! the JSON output, or a log line, because the usual reason to run this is a
6//! failing boot on a shared screen.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::path::Path;
12
13use anyhow::{Context, Result};
14use chrono::{DateTime, Utc};
15use serde::Serialize;
16use systemprompt_config::{
17    ProfileBootstrap, ResolvedSource, SecretsBootstrap, SecretsProvider, VaultKvProvider,
18    build_external_provider, process_env_lookup,
19};
20use systemprompt_manifest::profile::resolve_with_home;
21use systemprompt_manifest::secrets::OAUTH_AT_REST_PEPPER_MIN_LENGTH;
22
23use crate::CliConfig;
24use crate::shared::{CommandOutput, render_result};
25
26pub const REQUIRED_SECRET_KEYS: &[&str] = &["oauth_at_rest_pepper", "database_url"];
27pub const DATABASE_URL_ALIASES: &[&str] = &["database_url", "internal_database_url"];
28
29#[derive(Debug, Serialize)]
30pub struct SecretCheckReport {
31    pub source: String,
32    pub detail: String,
33    pub keys: Vec<String>,
34    pub missing_required: Vec<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub generation: Option<u64>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub last_reload_at: Option<DateTime<Utc>>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub last_error: Option<String>,
41}
42
43pub async fn execute(config: &CliConfig) -> Result<()> {
44    let report = run().await?;
45    render_result(
46        &CommandOutput::card_value("Secrets Source", &report),
47        config,
48    );
49    Ok(())
50}
51
52pub async fn run() -> Result<SecretCheckReport> {
53    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
54    let profile_path = ProfileBootstrap::get_path().context("Failed to locate the profile")?;
55    let profile_dir = Path::new(profile_path)
56        .parent()
57        .context("Profile path has no parent directory")?;
58
59    let is_deployment_host =
60        systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
61    let is_subprocess = std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok();
62    let has_pepper = std::env::var("OAUTH_AT_REST_PEPPER")
63        .is_ok_and(|pepper| pepper.len() >= OAUTH_AT_REST_PEPPER_MIN_LENGTH);
64
65    let resolved = systemprompt_config::resolve_source(
66        profile.secrets.as_ref(),
67        is_subprocess,
68        is_deployment_host,
69        has_pepper,
70    )
71    .context("The profile's secrets configuration is not usable")?;
72
73    let (source, detail, keys) = describe(&resolved, profile_dir).await?;
74    let status = SecretsBootstrap::status();
75    Ok(SecretCheckReport {
76        missing_required: missing_required(&keys),
77        source,
78        detail,
79        keys,
80        generation: status.as_ref().map(|s| s.generation),
81        last_reload_at: status.as_ref().and_then(|s| s.last_reload_at),
82        last_error: status.and_then(|s| s.last_error),
83    })
84}
85
86async fn describe(
87    resolved: &ResolvedSource<'_>,
88    profile_dir: &Path,
89) -> Result<(String, String, Vec<String>)> {
90    match resolved {
91        ResolvedSource::Vault(cfg) => {
92            let provider = VaultKvProvider::from_config(cfg, |name| std::env::var(name).ok())
93                .context("Vault client could not be built from the profile")?;
94            let detail = provider.describe();
95            let document = provider
96                .fetch()
97                .await
98                .context("Vault document could not be read")?;
99            Ok(("vault".to_owned(), detail, document.key_names()))
100        },
101        ResolvedSource::External(cfg) => {
102            let provider =
103                build_external_provider(cfg, process_env_lookup()).with_context(|| {
104                    format!("External secrets source '{}' could not be built", cfg.kind)
105                })?;
106            let detail = provider.describe();
107            let document = provider
108                .fetch_boxed()
109                .await
110                .context("External secrets document could not be read")?;
111            Ok(("external".to_owned(), detail, document.key_names()))
112        },
113        ResolvedSource::SubprocessEnv => Ok((
114            "subprocess-env".to_owned(),
115            "inherited from the parent process".to_owned(),
116            env_key_names(),
117        )),
118        ResolvedSource::DeploymentHostEnv => Ok((
119            "deployment-host-env".to_owned(),
120            "provided by the deployment host".to_owned(),
121            env_key_names(),
122        )),
123        ResolvedSource::LocalEnvWithFileFallback(path) | ResolvedSource::File(path) => {
124            let resolved_path = resolve_with_home(profile_dir, path);
125            let keys = file_key_names(&resolved_path)?;
126            Ok(("file".to_owned(), resolved_path.display().to_string(), keys))
127        },
128    }
129}
130
131pub fn file_key_names(path: &Path) -> Result<Vec<String>> {
132    let raw = std::fs::read_to_string(path)
133        .with_context(|| format!("Failed to read {}", path.display()))?;
134    // JSON: the secrets document is operator-authored and read here only for
135    // its key names; values are never touched.
136    let parsed: serde_json::Value = serde_json::from_str(&raw)
137        .with_context(|| format!("Failed to parse {}", path.display()))?;
138    let mut names: Vec<String> = parsed
139        .as_object()
140        .map(|object| object.keys().cloned().collect())
141        .unwrap_or_default();
142    names.sort();
143    Ok(names)
144}
145
146fn env_key_names() -> Vec<String> {
147    let mut names: Vec<String> = REQUIRED_SECRET_KEYS
148        .iter()
149        .chain(DATABASE_URL_ALIASES.iter())
150        .filter(|name| std::env::var(name.to_uppercase()).is_ok())
151        .map(|name| (*name).to_owned())
152        .collect();
153    names.sort();
154    names.dedup();
155    names
156}
157
158#[must_use]
159pub fn missing_required(keys: &[String]) -> Vec<String> {
160    let present = |name: &str| keys.iter().any(|key| key.eq_ignore_ascii_case(name));
161    let mut missing = Vec::new();
162    for required in REQUIRED_SECRET_KEYS {
163        if *required == "database_url" {
164            if !DATABASE_URL_ALIASES.iter().any(|alias| present(alias)) {
165                missing.push("database_url (or internal_database_url)".to_owned());
166            }
167        } else if !present(required) {
168            missing.push((*required).to_owned());
169        }
170    }
171    missing
172}