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