systemprompt-config 0.67.0

Profile-based configuration for systemprompt.io AI governance infrastructure. Bootstraps profiles, secrets, and credentials with zero environment-variable fallback.
Documentation
//! Dispatcher from the resolved source to the loader that serves it.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use systemprompt_manifest::profile::VaultSecretsConfig;
use systemprompt_manifest::secrets::{OAUTH_AT_REST_PEPPER_MIN_LENGTH, Secrets};

use super::SecretsBootstrapError;
use super::logging::log_secrets_failure;
use super::provider::DynSecretsProvider;
use super::registry::{build_external_provider, process_env_lookup};
use super::resolve::{ResolvedSource, resolve_source};
use super::vault::VaultKvProvider;
use crate::bootstrap::profile::ProfileBootstrap;
use crate::bootstrap::secrets::sources::{env, file};
use crate::error::{ConfigError, ConfigResult};

pub(super) async fn load_from_profile_config() -> ConfigResult<Secrets> {
    let is_deployment_host =
        systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok());
    let is_subprocess = std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok();
    let has_valid_pepper_in_env = std::env::var("OAUTH_AT_REST_PEPPER")
        .is_ok_and(|pepper| pepper.len() >= OAUTH_AT_REST_PEPPER_MIN_LENGTH);

    let secrets_config = match ProfileBootstrap::get() {
        Ok(profile) => profile.secrets.as_ref(),
        Err(_e) if (is_subprocess || is_deployment_host) && has_valid_pepper_in_env => None,
        Err(_e) => return Err(SecretsBootstrapError::ProfileNotInitialized.into()),
    };
    let resolved = resolve_source(
        secrets_config,
        is_subprocess,
        is_deployment_host,
        has_valid_pepper_in_env,
    )?;

    match resolved {
        ResolvedSource::SubprocessEnv | ResolvedSource::DeploymentHostEnv => {
            tracing::debug!("Loading secrets from environment");
            env::load_from_env()
        },
        // Why: only an absent file may fall through to the environment; a
        // present-but-malformed or unreadable file is a defect the operator
        // must see, not a reason to boot on whatever the host carries.
        ResolvedSource::LocalEnvWithFileFallback(path) => {
            tracing::debug!("Profile source is 'env' but running locally, trying file first");
            match file::resolve_and_load_file(path) {
                Err(ConfigError::Secrets(SecretsBootstrapError::FileNotFound { path })) => {
                    tracing::debug!(path, "No secrets file; falling back to environment");
                    env::load_from_env()
                },
                other => other,
            }
        },
        ResolvedSource::File(path) => {
            tracing::debug!("Loading secrets from file (profile source: file)");
            file::resolve_and_load_file(path).inspect_err(log_secrets_failure)
        },
        ResolvedSource::Vault(cfg) => load_from_vault(cfg).await,
        ResolvedSource::External(cfg) => {
            let provider = match build_external_provider(cfg, process_env_lookup()) {
                Ok(provider) => provider,
                Err(e) => return Err(fail_closed(e)),
            };
            load_from_provider(provider.as_ref(), "external").await
        },
    }
}

async fn load_from_vault(cfg: &VaultSecretsConfig) -> ConfigResult<Secrets> {
    let provider = match VaultKvProvider::from_config(cfg, |name| std::env::var(name).ok()) {
        Ok(provider) => provider,
        Err(e) => return Err(fail_closed(SecretsBootstrapError::from(e))),
    };
    load_from_provider(&provider, "vault").await
}

async fn load_from_provider(
    provider: &dyn DynSecretsProvider,
    source_name: &str,
) -> ConfigResult<Secrets> {
    // Why: a remote-source failure is never rescued by the environment — a
    // downgraded boot would run on whatever stale credentials the host carries.
    tracing::debug!(source = %provider.describe(), source_name, "loading secrets from provider");

    let document = match provider.fetch_boxed().await {
        Ok(document) => document,
        Err(e) => return Err(fail_closed(e)),
    };
    let key_names = document.key_names();

    match document.into_secrets() {
        Ok(secrets) => {
            tracing::debug!(keys = ?key_names, source_name, "secrets document parsed");
            Ok(secrets)
        },
        Err(e) => Err(fail_closed(e)),
    }
}

fn fail_closed(e: SecretsBootstrapError) -> ConfigError {
    let error = ConfigError::from(e);
    log_secrets_failure(&error);
    error
}