systemprompt_cloud/credentials_bootstrap/
mod.rs1mod 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}