Skip to main content

systemprompt_cloud/credentials_bootstrap/
mod.rs

1//! Cloud credentials bootstrap: loads and validates the operator's cloud
2//! credentials and hands them to the caller, which owns them from there.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7mod 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}