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