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