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