Skip to main content

systemprompt_cloud/credentials_bootstrap/
mod.rs

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