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 serde::Serialize;
15use systemprompt_config::{ProfileBootstrap, ResolvedSource, SecretsProvider, VaultKvProvider};
16use systemprompt_models::profile::resolve_with_home;
17use systemprompt_models::secrets::OAUTH_AT_REST_PEPPER_MIN_LENGTH;
18
19use crate::CliConfig;
20use crate::shared::{CommandOutput, render_result};
21
22pub const REQUIRED_SECRET_KEYS: &[&str] = &["oauth_at_rest_pepper", "database_url"];
23pub const DATABASE_URL_ALIASES: &[&str] = &["database_url", "internal_database_url"];
24
25#[derive(Debug, Serialize)]
26pub struct SecretCheckReport {
27    pub source: String,
28    pub detail: String,
29    pub keys: Vec<String>,
30    pub missing_required: Vec<String>,
31}
32
33pub async fn execute(config: &CliConfig) -> Result<()> {
34    let report = run().await?;
35    render_result(
36        &CommandOutput::card_value("Secrets Source", &report),
37        config,
38    );
39    Ok(())
40}
41
42pub async fn run() -> Result<SecretCheckReport> {
43    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
44    let profile_path = ProfileBootstrap::get_path().context("Failed to locate the profile")?;
45    let profile_dir = Path::new(profile_path)
46        .parent()
47        .context("Profile path has no parent directory")?;
48
49    let is_deployment_host =
50        systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
51    let is_subprocess = std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok();
52    let has_pepper = std::env::var("OAUTH_AT_REST_PEPPER")
53        .is_ok_and(|pepper| pepper.len() >= OAUTH_AT_REST_PEPPER_MIN_LENGTH);
54
55    let resolved = systemprompt_config::resolve_source(
56        profile.secrets.as_ref(),
57        is_subprocess,
58        is_deployment_host,
59        has_pepper,
60    )
61    .context("The profile's secrets configuration is not usable")?;
62
63    let (source, detail, keys) = describe(&resolved, profile_dir).await?;
64    Ok(SecretCheckReport {
65        missing_required: missing_required(&keys),
66        source,
67        detail,
68        keys,
69    })
70}
71
72async fn describe(
73    resolved: &ResolvedSource<'_>,
74    profile_dir: &Path,
75) -> Result<(String, String, Vec<String>)> {
76    match resolved {
77        ResolvedSource::Vault(cfg) => {
78            let provider = VaultKvProvider::from_config(cfg, |name| std::env::var(name).ok())
79                .context("Vault client could not be built from the profile")?;
80            let detail = provider.describe();
81            let document = provider
82                .fetch()
83                .await
84                .context("Vault document could not be read")?;
85            Ok(("vault".to_owned(), detail, document.key_names()))
86        },
87        ResolvedSource::SubprocessEnv => Ok((
88            "subprocess-env".to_owned(),
89            "inherited from the parent process".to_owned(),
90            env_key_names(),
91        )),
92        ResolvedSource::DeploymentHostEnv => Ok((
93            "deployment-host-env".to_owned(),
94            "provided by the deployment host".to_owned(),
95            env_key_names(),
96        )),
97        ResolvedSource::LocalEnvWithFileFallback(path) | ResolvedSource::File(path) => {
98            let resolved_path = resolve_with_home(profile_dir, path);
99            let keys = file_key_names(&resolved_path)?;
100            Ok(("file".to_owned(), resolved_path.display().to_string(), keys))
101        },
102    }
103}
104
105pub fn file_key_names(path: &Path) -> Result<Vec<String>> {
106    let raw = std::fs::read_to_string(path)
107        .with_context(|| format!("Failed to read {}", path.display()))?;
108    // JSON: the secrets document is operator-authored and read here only for
109    // its key names; values are never touched.
110    let parsed: serde_json::Value = serde_json::from_str(&raw)
111        .with_context(|| format!("Failed to parse {}", path.display()))?;
112    let mut names: Vec<String> = parsed
113        .as_object()
114        .map(|object| object.keys().cloned().collect())
115        .unwrap_or_default();
116    names.sort();
117    Ok(names)
118}
119
120fn env_key_names() -> Vec<String> {
121    let mut names: Vec<String> = REQUIRED_SECRET_KEYS
122        .iter()
123        .chain(DATABASE_URL_ALIASES.iter())
124        .filter(|name| std::env::var(name.to_uppercase()).is_ok())
125        .map(|name| (*name).to_owned())
126        .collect();
127    names.sort();
128    names.dedup();
129    names
130}
131
132#[must_use]
133pub fn missing_required(keys: &[String]) -> Vec<String> {
134    let present = |name: &str| keys.iter().any(|key| key.eq_ignore_ascii_case(name));
135    let mut missing = Vec::new();
136    for required in REQUIRED_SECRET_KEYS {
137        if *required == "database_url" {
138            if !DATABASE_URL_ALIASES.iter().any(|alias| present(alias)) {
139                missing.push("database_url (or internal_database_url)".to_owned());
140            }
141        } else if !present(required) {
142            missing.push((*required).to_owned());
143        }
144    }
145    missing
146}