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(|name| std::env::var(name).ok())
91 }
92
93 fn allow_unvalidated() -> bool {
94 std::env::var("SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS").as_deref() == Ok("1")
95 }
96
97 fn load_from_env() -> Option<CloudCredentials> {
98 let api_token = CloudAuthToken::new(read_env_optional("SYSTEMPROMPT_API_TOKEN")?);
99 let user_email = match Email::try_new(read_env_optional("SYSTEMPROMPT_USER_EMAIL")?) {
100 Ok(email) => email,
101 Err(error) => {
102 tracing::warn!(error = %error, "SYSTEMPROMPT_USER_EMAIL is not a valid address");
103 return None;
104 },
105 };
106
107 tracing::debug!("Loading cloud credentials from environment variables");
108
109 Some(CloudCredentials {
110 api_token,
111 api_url: read_env_optional("SYSTEMPROMPT_API_URL")
112 .unwrap_or_else(|| crate::constants::api::PRODUCTION_URL.into()),
113 authenticated_at: Utc::now(),
114 user_email,
115 last_validated_at: None,
116 })
117 }
118
119 pub fn read_unvalidated() -> CloudResult<Option<CloudCredentials>> {
120 if Self::is_deployment_host() {
121 return Ok(Self::load_from_env());
122 }
123 let cloud_paths = crate::paths::get_cloud_paths();
124 let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
125 Self::load_credentials_from_path(&credentials_path).map(Some)
126 }
127
128 pub async fn require() -> CloudResult<CloudCredentials> {
129 Self::load()
130 .await?
131 .ok_or_else(|| CredentialsBootstrapError::NotAvailable.into())
132 }
133
134 pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
135 let cloud_paths = crate::paths::get_cloud_paths();
136 let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
137
138 let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
139 CredentialsBootstrapError::InvalidCredentials {
140 source: Box::new(e),
141 }
142 })?;
143
144 Self::validate_with_api(&creds).await.map_err(|e| {
145 CredentialsBootstrapError::ApiValidationFailed {
146 message: "credentials rejected by the cloud API".to_owned(),
147 source: Box::new(e),
148 }
149 })?;
150
151 Ok(creds)
152 }
153
154 fn load_credentials_from_path(path: &Path) -> CloudResult<CloudCredentials> {
155 let creds = CloudCredentials::load_from_path(path).map_err(|e| {
156 if path.exists() {
157 CloudError::from(CredentialsBootstrapError::InvalidCredentials {
158 source: Box::new(e),
159 })
160 } else {
161 CloudError::from(CredentialsBootstrapError::FileNotFound {
162 path: path.display().to_string(),
163 })
164 }
165 })?;
166
167 if creds.is_token_expired() {
168 return Err(CredentialsBootstrapError::TokenExpired.into());
169 }
170
171 if creds.expires_within(Duration::hours(1)) {
172 tracing::warn!(
173 "Cloud token will expire soon. Consider running 'systemprompt cloud auth login' to \
174 refresh."
175 );
176 }
177
178 tracing::debug!(path = %path.display(), user = ?creds.user_email, "Loaded cloud credentials");
179
180 Ok(creds)
181 }
182}