Skip to main content

systemprompt_cloud/tenants/
mod.rs

1//! On-disk representation of cloud tenants the CLI knows about.
2//!
3//! [`StoredTenant`] is the per-tenant record; [`TenantStore`] (in
4//! `tenant_store.rs`) is the persistent map keyed by tenant id.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod tenant_store;
10
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::TenantId;
13use validator::Validate;
14
15use crate::api_client::TenantInfo;
16
17pub use tenant_store::TenantStore;
18
19#[derive(Debug)]
20pub struct NewCloudTenantParams {
21    pub id: TenantId,
22    pub name: String,
23    pub app_id: Option<String>,
24    pub hostname: Option<String>,
25    pub region: Option<String>,
26    pub database_url: Option<String>,
27    pub internal_database_url: String,
28    pub external_db_access: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum TenantType {
34    #[default]
35    Local,
36    Cloud,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
40pub struct StoredTenant {
41    pub id: TenantId,
42
43    #[validate(length(min = 1, message = "Tenant name cannot be empty"))]
44    pub name: String,
45
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub app_id: Option<String>,
48
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub hostname: Option<String>,
51
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub region: Option<String>,
54
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub database_url: Option<String>,
57
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub internal_database_url: Option<String>,
60
61    #[serde(default)]
62    pub tenant_type: TenantType,
63
64    #[serde(default)]
65    pub external_db_access: bool,
66
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub docker_project: Option<String>,
69}
70
71impl StoredTenant {
72    #[must_use]
73    pub fn new(id: TenantId, name: String) -> Self {
74        Self {
75            id,
76            name,
77            app_id: None,
78            hostname: None,
79            region: None,
80            database_url: None,
81            internal_database_url: None,
82            tenant_type: TenantType::default(),
83            external_db_access: false,
84            docker_project: None,
85        }
86    }
87
88    #[must_use]
89    pub const fn new_local(id: TenantId, name: String, database_url: String) -> Self {
90        Self {
91            id,
92            name,
93            app_id: None,
94            hostname: None,
95            region: None,
96            database_url: Some(database_url),
97            internal_database_url: None,
98            tenant_type: TenantType::Local,
99            external_db_access: false,
100            docker_project: None,
101        }
102    }
103
104    #[must_use]
105    pub const fn new_local_docker(
106        id: TenantId,
107        name: String,
108        database_url: String,
109        docker_project: String,
110    ) -> Self {
111        Self {
112            id,
113            name,
114            app_id: None,
115            hostname: None,
116            region: None,
117            database_url: Some(database_url),
118            internal_database_url: None,
119            tenant_type: TenantType::Local,
120            external_db_access: false,
121            docker_project: Some(docker_project),
122        }
123    }
124
125    #[must_use]
126    pub fn new_cloud(params: NewCloudTenantParams) -> Self {
127        Self {
128            id: params.id,
129            name: params.name,
130            app_id: params.app_id,
131            hostname: params.hostname,
132            region: params.region,
133            database_url: params.database_url,
134            internal_database_url: Some(params.internal_database_url),
135            tenant_type: TenantType::Cloud,
136            external_db_access: params.external_db_access,
137            docker_project: None,
138        }
139    }
140
141    #[must_use]
142    pub fn from_tenant_info(info: &TenantInfo) -> Self {
143        Self {
144            id: TenantId::new(info.id.clone()),
145            name: info.name.clone(),
146            app_id: info.app_id.clone(),
147            hostname: info.hostname.clone(),
148            region: info.region.clone(),
149            database_url: None,
150            internal_database_url: Some(info.database_url.clone()),
151            tenant_type: TenantType::Cloud,
152            external_db_access: info.external_db_access,
153            docker_project: None,
154        }
155    }
156
157    #[must_use]
158    pub const fn uses_managed_container(&self) -> bool {
159        self.docker_project.is_some()
160    }
161
162    #[must_use]
163    pub fn has_database_url(&self) -> bool {
164        match self.tenant_type {
165            TenantType::Cloud => self
166                .internal_database_url
167                .as_ref()
168                .is_some_and(|url| !url.is_empty()),
169            TenantType::Local => self
170                .database_url
171                .as_ref()
172                .is_some_and(|url| !url.is_empty()),
173        }
174    }
175
176    #[must_use]
177    pub fn get_local_database_url(&self) -> Option<&String> {
178        self.database_url
179            .as_ref()
180            .or(self.internal_database_url.as_ref())
181    }
182
183    #[must_use]
184    pub const fn is_cloud(&self) -> bool {
185        matches!(self.tenant_type, TenantType::Cloud)
186    }
187
188    #[must_use]
189    pub const fn is_local(&self) -> bool {
190        matches!(self.tenant_type, TenantType::Local)
191    }
192
193    pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
194        self.name.clone_from(&info.name);
195        self.app_id.clone_from(&info.app_id);
196        self.hostname.clone_from(&info.hostname);
197        self.region.clone_from(&info.region);
198        self.external_db_access = info.external_db_access;
199
200        if !info.database_url.contains(":***@") {
201            self.internal_database_url = Some(info.database_url.clone());
202        }
203    }
204
205    #[must_use]
206    pub fn is_database_url_masked(&self) -> bool {
207        self.internal_database_url
208            .as_ref()
209            .is_some_and(|url| url.contains(":***@") || url.contains(":********@"))
210    }
211
212    #[must_use]
213    pub fn has_missing_credentials(&self) -> bool {
214        self.tenant_type == TenantType::Cloud && self.is_database_url_masked()
215    }
216}