Skip to main content

architect_sdk/
tenant.rs

1//! Multi-tenant registry: strategy and config per tenant, loaded from central DB.
2
3use crate::db::pool::Pool;
4use crate::error::AppError;
5use crate::store::qualified_sys_table;
6use std::collections::HashMap;
7
8/// Default tenant id that identifies the Platform Admin — the only principal allowed to write
9/// `global` tables. Overridable via the `ARCHITECT_PLATFORM_TENANT` env var. A normal RLS request
10/// runs `SET LOCAL app.tenant_id = '<tenant>'`; only this id satisfies the write policies that
11/// `migration::apply_rls_to_tables` installs on global tables, so non-admin tenants get read-only
12/// access enforced at the database level.
13pub const DEFAULT_PLATFORM_TENANT_ID: &str = "_platform";
14
15/// The configured Platform Admin tenant id (env `ARCHITECT_PLATFORM_TENANT`, else `_platform`).
16/// Used both when generating RLS policies (migration) and when authorizing writes (handlers), so
17/// the two must agree across processes that share a database.
18pub 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
26/// Optional app-wide tenant-strategy override read from the `ARCHITECT_TENANT_STRATEGY` env var
27/// (`"rls"` or `"database"`). When set, **every** tenant runs under this single strategy regardless
28/// of its `_sys_tenants.strategy` value — useful to pin a whole deployment to one model. When unset
29/// (the default), each tenant uses its own stored strategy. Unrecognized values are ignored
30/// (treated as unset). See [`load_registry_from_pool`] for how the override is applied.
31pub 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/// Tenant isolation strategy.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum TenantStrategy {
42    /// Tenant has its own PostgreSQL database (own pool).
43    Database,
44    /// Tenant shares DB and schema; isolation via RLS and app.tenant_id.
45    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/// Per-tenant config from _sys_tenants.
64#[derive(Clone, Debug)]
65pub struct TenantEntry {
66    pub strategy: TenantStrategy,
67    /// Required when strategy = Database. Optional for RLS (when set, app data uses that DB; config stays in architect DB).
68    pub database_url: Option<String>,
69}
70
71/// In-memory tenant registry loaded from central DB. Thread-safe via Arc.
72#[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    /// All Database-strategy tenants as (tenant_id, database_url).
93    /// Used by the DDL broadcast to know which dedicated databases need migration.
94    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    /// True if any RLS tenants share the central architect DB (no database_url).
111    /// When true, the broadcast must run DDL on the central pool once for all such tenants.
112    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    /// RLS tenants that have their own dedicated database_url (not the central DB).
119    /// DDL is run per unique URL with rls_tenant_column enabled.
120    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
137/// Load tenant registry from architect._sys_tenants. Invalid rows are skipped (missing database_url for database strategy).
138pub 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        // Effective strategy: the app-wide override when set, else the per-tenant stored value.
163        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        // Under forced RLS we run a single shared central DB (greenfield), so any per-tenant
177        // database_url is ignored and every tenant shares the architect DB with RLS policies.
178        // Otherwise keep the configured URL (dedicated DB for Database strategy, or a dedicated
179        // RLS DB when set per tenant).
180        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    // Mutates a process-global env var; no other test reads ARCHITECT_TENANT_STRATEGY, so this is
222    // safe. Sets, asserts, and restores the prior value.
223    #[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}