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