Skip to main content

systemprompt_cloud/credentials_bootstrap/
mod.rs

1//! Process-wide cloud credentials bootstrap.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6mod error;
7
8use std::path::Path;
9use std::sync::OnceLock;
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
20static CREDENTIALS: OnceLock<Option<CloudCredentials>> = OnceLock::new();
21
22const POD_CREDENTIALS_REJECTED: &str = "tenant pod credentials rejected by api.systemprompt.io \
23                                        (token in SYSTEMPROMPT_API_TOKEN). Re-run 'systemprompt \
24                                        cloud deploy' or set \
25                                        SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS=1 to bypass";
26
27#[derive(Debug, Clone, Copy)]
28pub struct CredentialsBootstrap;
29
30impl CredentialsBootstrap {
31    pub async fn init() -> CloudResult<Option<&'static CloudCredentials>> {
32        if CREDENTIALS.get().is_some() {
33            return Err(CredentialsBootstrapError::AlreadyInitialized.into());
34        }
35
36        if Self::is_deployment_host() {
37            tracing::debug!("Deployment host detected, loading credentials from environment");
38            let creds = Self::load_from_env();
39            if let Some(ref c) = creds
40                && let Err(e) = Self::validate_with_api(c).await
41            {
42                if Self::allow_unvalidated() {
43                    tracing::warn!(
44                        target: "security_audit",
45                        error = %e,
46                        "cloud credentials unvalidated; proceeding under SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS=1"
47                    );
48                } else {
49                    return Err(CredentialsBootstrapError::ApiValidationFailed {
50                        message: POD_CREDENTIALS_REJECTED.to_owned(),
51                        source: Box::new(e),
52                    }
53                    .into());
54                }
55            }
56            CREDENTIALS
57                .set(creds)
58                .map_err(|_e| CredentialsBootstrapError::AlreadyInitialized)?;
59            return Ok(CREDENTIALS
60                .get()
61                .ok_or(CredentialsBootstrapError::NotInitialized)?
62                .as_ref());
63        }
64
65        let cloud_paths = crate::paths::get_cloud_paths();
66        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
67
68        let mut creds = Self::load_credentials_from_path(&credentials_path)?;
69        if Self::validation_is_fresh(&creds) {
70            tracing::debug!("Cloud credentials within validation TTL; skipping API round-trip");
71        } else {
72            Self::validate_with_api(&creds).await?;
73            creds.last_validated_at = Some(Utc::now());
74            if let Err(e) = creds.save_to_path(&credentials_path) {
75                tracing::debug!(error = %e, "failed to persist credential validation timestamp");
76            }
77        }
78
79        CREDENTIALS
80            .set(Some(creds))
81            .map_err(|_e| CredentialsBootstrapError::AlreadyInitialized)?;
82        Ok(CREDENTIALS
83            .get()
84            .ok_or(CredentialsBootstrapError::NotInitialized)?
85            .as_ref())
86    }
87
88    async fn validate_with_api(creds: &CloudCredentials) -> CloudResult<()> {
89        let client = CloudApiClient::new(&creds.api_url, creds.api_token.as_str())?;
90        client.get_user().await?;
91        tracing::debug!("Cloud credentials validated with API");
92        Ok(())
93    }
94
95    fn validation_is_fresh(creds: &CloudCredentials) -> bool {
96        let Some(last) = creds.last_validated_at else {
97            return false;
98        };
99        if creds.expires_within(Duration::hours(1)) {
100            return false;
101        }
102        let age = Utc::now().signed_duration_since(last);
103        age >= Duration::zero()
104            && age < Duration::seconds(crate::constants::credentials::VALIDATION_TTL_SECS)
105    }
106
107    fn is_deployment_host() -> bool {
108        systemprompt_models::subprocess::is_deployment_host(|name| std::env::var(name).ok())
109    }
110
111    fn allow_unvalidated() -> bool {
112        std::env::var("SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS").as_deref() == Ok("1")
113    }
114
115    fn load_from_env() -> Option<CloudCredentials> {
116        let api_token = CloudAuthToken::new(read_env_optional("SYSTEMPROMPT_API_TOKEN")?);
117        let user_email = match Email::try_new(read_env_optional("SYSTEMPROMPT_USER_EMAIL")?) {
118            Ok(email) => email,
119            Err(error) => {
120                tracing::warn!(error = %error, "SYSTEMPROMPT_USER_EMAIL is not a valid address");
121                return None;
122            },
123        };
124
125        tracing::debug!("Loading cloud credentials from environment variables");
126
127        Some(CloudCredentials {
128            api_token,
129            api_url: read_env_optional("SYSTEMPROMPT_API_URL")
130                .unwrap_or_else(|| crate::constants::api::PRODUCTION_URL.into()),
131            authenticated_at: Utc::now(),
132            user_email,
133            last_validated_at: None,
134        })
135    }
136
137    pub fn get() -> Result<Option<&'static CloudCredentials>, CredentialsBootstrapError> {
138        CREDENTIALS
139            .get()
140            .map(|opt| opt.as_ref())
141            .ok_or(CredentialsBootstrapError::NotInitialized)
142    }
143
144    pub fn require() -> Result<&'static CloudCredentials, CredentialsBootstrapError> {
145        Self::get()?.ok_or(CredentialsBootstrapError::NotAvailable)
146    }
147
148    #[must_use]
149    pub fn is_initialized() -> bool {
150        CREDENTIALS.get().is_some()
151    }
152
153    pub fn init_empty() {
154        if CREDENTIALS.set(None).is_err() {
155            tracing::debug!("Credentials cell already initialised; init_empty is a no-op");
156        }
157    }
158
159    pub async fn try_init() -> CloudResult<Option<&'static CloudCredentials>> {
160        if CREDENTIALS.get().is_some() {
161            return Self::get().map_err(Into::into);
162        }
163        Self::init().await
164    }
165
166    #[must_use]
167    pub fn expires_within(duration: Duration) -> bool {
168        match Self::get() {
169            Ok(Some(c)) => c.expires_within(duration),
170            Ok(None) => false,
171            Err(e) => {
172                tracing::debug!(error = %e, "Credentials not available for expiry check");
173                false
174            },
175        }
176    }
177
178    pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
179        let cloud_paths = crate::paths::get_cloud_paths();
180        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
181
182        let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
183            CredentialsBootstrapError::InvalidCredentials {
184                source: Box::new(e),
185            }
186        })?;
187
188        Self::validate_with_api(&creds).await.map_err(|e| {
189            CredentialsBootstrapError::ApiValidationFailed {
190                message: "credentials rejected by the cloud API".to_owned(),
191                source: Box::new(e),
192            }
193        })?;
194
195        Ok(creds)
196    }
197
198    fn load_credentials_from_path(path: &Path) -> CloudResult<CloudCredentials> {
199        let creds = CloudCredentials::load_from_path(path).map_err(|e| {
200            if path.exists() {
201                CloudError::from(CredentialsBootstrapError::InvalidCredentials {
202                    source: Box::new(e),
203                })
204            } else {
205                CloudError::from(CredentialsBootstrapError::FileNotFound {
206                    path: path.display().to_string(),
207                })
208            }
209        })?;
210
211        if creds.is_token_expired() {
212            return Err(CredentialsBootstrapError::TokenExpired.into());
213        }
214
215        if creds.expires_within(Duration::hours(1)) {
216            tracing::warn!(
217                "Cloud token will expire soon. Consider running 'systemprompt cloud auth login' to \
218                 refresh."
219            );
220        }
221
222        tracing::debug!(path = %path.display(), user = ?creds.user_email, "Loaded cloud credentials");
223
224        Ok(creds)
225    }
226}