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