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