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