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