Skip to main content

systemprompt_cloud/credentials_bootstrap/
mod.rs

1//! Cloud credentials bootstrap: loads and validates the operator's cloud
2//! credentials and hands them to the caller, which owns them from there.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7mod error;
8
9use std::path::Path;
10
11use chrono::{Duration, Utc};
12use systemprompt_identifiers::{CloudAuthToken, Email};
13use systemprompt_manifest::read_env_optional;
14
15pub use error::CredentialsBootstrapError;
16
17use crate::error::{CloudError, CloudResult};
18use crate::{CloudApiClient, CloudCredentials};
19
20const POD_CREDENTIALS_REJECTED: &str = "tenant pod credentials rejected by api.systemprompt.io \
21                                        (token in SYSTEMPROMPT_API_TOKEN). Re-run 'systemprompt \
22                                        cloud deploy' or set \
23                                        SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS=1 to bypass";
24
25#[derive(Debug, Clone, Copy)]
26pub struct CredentialsBootstrap;
27
28impl CredentialsBootstrap {
29    pub async fn load() -> CloudResult<Option<CloudCredentials>> {
30        if Self::is_deployment_host() {
31            tracing::debug!("Deployment host detected, loading credentials from environment");
32            let creds = Self::load_from_env();
33            if let Some(ref c) = creds
34                && let Err(e) = Self::validate_with_api(c).await
35            {
36                if Self::allow_unvalidated() {
37                    tracing::warn!(
38                        target: "security_audit",
39                        error = %e,
40                        "cloud credentials unvalidated; proceeding under SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS=1"
41                    );
42                } else {
43                    return Err(CredentialsBootstrapError::ApiValidationFailed {
44                        message: POD_CREDENTIALS_REJECTED.to_owned(),
45                        source: Box::new(e),
46                    }
47                    .into());
48                }
49            }
50            return Ok(creds);
51        }
52
53        let cloud_paths = crate::paths::get_cloud_paths();
54        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
55
56        let mut creds = Self::load_credentials_from_path(&credentials_path)?;
57        if Self::validation_is_fresh(&creds) {
58            tracing::debug!("Cloud credentials within validation TTL; skipping API round-trip");
59        } else {
60            Self::validate_with_api(&creds).await?;
61            creds.last_validated_at = Some(Utc::now());
62            if let Err(e) = creds.save_to_path(&credentials_path) {
63                tracing::debug!(error = %e, "failed to persist credential validation timestamp");
64            }
65        }
66
67        Ok(Some(creds))
68    }
69
70    async fn validate_with_api(creds: &CloudCredentials) -> CloudResult<()> {
71        let client = CloudApiClient::new(&creds.api_url, creds.api_token.as_str())?;
72        client.get_user().await?;
73        tracing::debug!("Cloud credentials validated with API");
74        Ok(())
75    }
76
77    fn validation_is_fresh(creds: &CloudCredentials) -> bool {
78        let Some(last) = creds.last_validated_at else {
79            return false;
80        };
81        if creds.expires_within(Duration::hours(1)) {
82            return false;
83        }
84        let age = Utc::now().signed_duration_since(last);
85        age >= Duration::zero()
86            && age < Duration::seconds(crate::constants::credentials::VALIDATION_TTL_SECS)
87    }
88
89    fn is_deployment_host() -> bool {
90        systemprompt_models::subprocess::is_deployment_host(
91            systemprompt_models::subprocess::ANY_DEPLOYMENT_HOST_MARKERS,
92            |name| std::env::var(name).ok(),
93        )
94    }
95
96    fn allow_unvalidated() -> bool {
97        std::env::var("SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS").as_deref() == Ok("1")
98    }
99
100    fn load_from_env() -> Option<CloudCredentials> {
101        let api_token = CloudAuthToken::new(read_env_optional("SYSTEMPROMPT_API_TOKEN")?);
102        let user_email = match Email::try_new(read_env_optional("SYSTEMPROMPT_USER_EMAIL")?) {
103            Ok(email) => email,
104            Err(error) => {
105                tracing::warn!(error = %error, "SYSTEMPROMPT_USER_EMAIL is not a valid address");
106                return None;
107            },
108        };
109
110        tracing::debug!("Loading cloud credentials from environment variables");
111
112        Some(CloudCredentials {
113            api_token,
114            api_url: read_env_optional("SYSTEMPROMPT_API_URL")
115                .unwrap_or_else(|| crate::constants::api::PRODUCTION_URL.into()),
116            authenticated_at: Utc::now(),
117            user_email,
118            last_validated_at: None,
119        })
120    }
121
122    pub fn read_unvalidated() -> CloudResult<Option<CloudCredentials>> {
123        if Self::is_deployment_host() {
124            return Ok(Self::load_from_env());
125        }
126        let cloud_paths = crate::paths::get_cloud_paths();
127        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
128        Self::load_credentials_from_path(&credentials_path).map(Some)
129    }
130
131    pub async fn require() -> CloudResult<CloudCredentials> {
132        Self::load()
133            .await?
134            .ok_or_else(|| CredentialsBootstrapError::NotAvailable.into())
135    }
136
137    pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
138        let cloud_paths = crate::paths::get_cloud_paths();
139        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
140
141        let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
142            CredentialsBootstrapError::InvalidCredentials {
143                source: Box::new(e),
144            }
145        })?;
146
147        Self::validate_with_api(&creds).await.map_err(|e| {
148            CredentialsBootstrapError::ApiValidationFailed {
149                message: "credentials rejected by the cloud API".to_owned(),
150                source: Box::new(e),
151            }
152        })?;
153
154        Ok(creds)
155    }
156
157    fn load_credentials_from_path(path: &Path) -> CloudResult<CloudCredentials> {
158        let creds = CloudCredentials::load_from_path(path).map_err(|e| {
159            if path.exists() {
160                CloudError::from(CredentialsBootstrapError::InvalidCredentials {
161                    source: Box::new(e),
162                })
163            } else {
164                CloudError::from(CredentialsBootstrapError::FileNotFound {
165                    path: path.display().to_string(),
166                })
167            }
168        })?;
169
170        if creds.is_token_expired() {
171            return Err(CredentialsBootstrapError::TokenExpired.into());
172        }
173
174        if creds.expires_within(Duration::hours(1)) {
175            tracing::warn!(
176                "Cloud token will expire soon. Consider running 'systemprompt cloud auth login' to \
177                 refresh."
178            );
179        }
180
181        tracing::debug!(path = %path.display(), user = ?creds.user_email, "Loaded cloud credentials");
182
183        Ok(creds)
184    }
185}