1use crate::db::pool::Pool;
4use crate::error::AppError;
5use crate::store::qualified_sys_table;
6use std::collections::HashMap;
7
8pub const DEFAULT_PLATFORM_TENANT_ID: &str = "_platform";
14
15pub fn platform_tenant_id() -> String {
19 std::env::var("ARCHITECT_PLATFORM_TENANT")
20 .ok()
21 .map(|s| s.trim().to_string())
22 .filter(|s| !s.is_empty())
23 .unwrap_or_else(|| DEFAULT_PLATFORM_TENANT_ID.to_string())
24}
25
26pub fn forced_tenant_strategy() -> Option<TenantStrategy> {
32 std::env::var("ARCHITECT_TENANT_STRATEGY")
33 .ok()
34 .map(|s| s.trim().to_string())
35 .filter(|s| !s.is_empty())
36 .and_then(|s| s.parse().ok())
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum TenantStrategy {
42 Database,
44 Rls,
46}
47
48impl std::str::FromStr for TenantStrategy {
49 type Err = AppError;
50
51 fn from_str(s: &str) -> Result<Self, Self::Err> {
52 match s.to_lowercase().as_str() {
53 "database" => Ok(TenantStrategy::Database),
54 "rls" => Ok(TenantStrategy::Rls),
55 _ => Err(AppError::BadRequest(format!(
56 "invalid tenant strategy: {} (expected database or rls)",
57 s
58 ))),
59 }
60 }
61}
62
63#[derive(Clone, Debug)]
65pub struct TenantEntry {
66 pub strategy: TenantStrategy,
67 pub database_url: Option<String>,
69}
70
71#[derive(Clone, Default)]
73pub struct TenantRegistry {
74 by_id: HashMap<String, TenantEntry>,
75}
76
77impl TenantRegistry {
78 pub fn new() -> Self {
79 TenantRegistry {
80 by_id: HashMap::new(),
81 }
82 }
83
84 pub fn get(&self, tenant_id: &str) -> Option<&TenantEntry> {
85 self.by_id.get(tenant_id)
86 }
87
88 pub fn is_empty(&self) -> bool {
89 self.by_id.is_empty()
90 }
91
92 pub fn database_tenant_targets(&self) -> Vec<(String, String)> {
95 self.by_id
96 .iter()
97 .filter_map(|(id, entry)| {
98 if matches!(entry.strategy, TenantStrategy::Database) {
99 entry
100 .database_url
101 .as_ref()
102 .map(|url| (id.clone(), url.clone()))
103 } else {
104 None
105 }
106 })
107 .collect()
108 }
109
110 pub fn has_shared_rls_tenants(&self) -> bool {
113 self.by_id
114 .values()
115 .any(|e| matches!(e.strategy, TenantStrategy::Rls) && e.database_url.is_none())
116 }
117
118 pub fn rls_dedicated_db_targets(&self) -> Vec<(String, String)> {
121 self.by_id
122 .iter()
123 .filter_map(|(id, entry)| {
124 if matches!(entry.strategy, TenantStrategy::Rls) {
125 entry
126 .database_url
127 .as_ref()
128 .map(|url| (id.clone(), url.clone()))
129 } else {
130 None
131 }
132 })
133 .collect()
134 }
135}
136
137pub async fn load_registry_from_pool(pool: &Pool) -> Result<TenantRegistry, AppError> {
139 let q_table = qualified_sys_table("_sys_tenants");
140 let sql = format!(
141 "SELECT id, strategy, database_url FROM {} ORDER BY id",
142 q_table
143 );
144 let rows = sqlx::query_as::<_, (String, String, Option<String>)>(&sql)
145 .fetch_all(pool)
146 .await?;
147
148 let forced = forced_tenant_strategy();
149 if let Some(s) = &forced {
150 let name = match s {
151 TenantStrategy::Database => "database",
152 TenantStrategy::Rls => "rls",
153 };
154 tracing::info!(
155 "ARCHITECT_TENANT_STRATEGY override active: all tenants run as '{}' strategy (per-tenant _sys_tenants.strategy ignored)",
156 name
157 );
158 }
159
160 let mut by_id = HashMap::new();
161 for (id, strategy_str, database_url) in rows {
162 let strategy = match &forced {
164 Some(s) => s.clone(),
165 None => {
166 if strategy_str.eq_ignore_ascii_case("schema") {
167 tracing::warn!(
168 "tenant {}: strategy 'schema' is no longer supported, skipping",
169 id
170 );
171 continue;
172 }
173 strategy_str.parse().map_err(|e: AppError| e)?
174 }
175 };
176 let database_url = if matches!(&forced, Some(TenantStrategy::Rls)) {
181 None
182 } else {
183 database_url.filter(|s| !s.is_empty())
184 };
185 if matches!(&strategy, TenantStrategy::Database) && database_url.is_none() {
186 tracing::warn!(
187 "tenant {}: database strategy requires database_url, skipping",
188 id
189 );
190 continue;
191 }
192 by_id.insert(
193 id,
194 TenantEntry {
195 strategy,
196 database_url,
197 },
198 );
199 }
200
201 Ok(TenantRegistry { by_id })
202}
203
204#[cfg(test)]
205mod strategy_override_tests {
206 use super::*;
207
208 #[test]
209 fn strategy_parses_rls_and_database_case_insensitively() {
210 assert_eq!(
211 "rls".parse::<TenantStrategy>().unwrap(),
212 TenantStrategy::Rls
213 );
214 assert_eq!(
215 "DATABASE".parse::<TenantStrategy>().unwrap(),
216 TenantStrategy::Database
217 );
218 assert!("bogus".parse::<TenantStrategy>().is_err());
219 }
220
221 #[test]
224 fn forced_strategy_reads_env() {
225 let prev = std::env::var("ARCHITECT_TENANT_STRATEGY").ok();
226
227 std::env::remove_var("ARCHITECT_TENANT_STRATEGY");
228 assert!(forced_tenant_strategy().is_none(), "unset = no override");
229
230 std::env::set_var("ARCHITECT_TENANT_STRATEGY", "rls");
231 assert_eq!(forced_tenant_strategy(), Some(TenantStrategy::Rls));
232
233 std::env::set_var("ARCHITECT_TENANT_STRATEGY", " database ");
234 assert_eq!(forced_tenant_strategy(), Some(TenantStrategy::Database));
235
236 std::env::set_var("ARCHITECT_TENANT_STRATEGY", "nonsense");
237 assert!(
238 forced_tenant_strategy().is_none(),
239 "unrecognized value is ignored"
240 );
241
242 match prev {
243 Some(v) => std::env::set_var("ARCHITECT_TENANT_STRATEGY", v),
244 None => std::env::remove_var("ARCHITECT_TENANT_STRATEGY"),
245 }
246 }
247}