use crate::db::pool::Pool;
use crate::error::AppError;
use crate::store::qualified_sys_table;
use std::collections::HashMap;
pub const DEFAULT_PLATFORM_TENANT_ID: &str = "_platform";
pub fn platform_tenant_id() -> String {
std::env::var("ARCHITECT_PLATFORM_TENANT")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| DEFAULT_PLATFORM_TENANT_ID.to_string())
}
pub fn forced_tenant_strategy() -> Option<TenantStrategy> {
std::env::var("ARCHITECT_TENANT_STRATEGY")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.and_then(|s| s.parse().ok())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TenantStrategy {
Database,
Rls,
}
impl std::str::FromStr for TenantStrategy {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"database" => Ok(TenantStrategy::Database),
"rls" => Ok(TenantStrategy::Rls),
_ => Err(AppError::BadRequest(format!(
"invalid tenant strategy: {} (expected database or rls)",
s
))),
}
}
}
#[derive(Clone, Debug)]
pub struct TenantEntry {
pub strategy: TenantStrategy,
pub database_url: Option<String>,
}
#[derive(Clone, Default)]
pub struct TenantRegistry {
by_id: HashMap<String, TenantEntry>,
}
impl TenantRegistry {
pub fn new() -> Self {
TenantRegistry {
by_id: HashMap::new(),
}
}
pub fn get(&self, tenant_id: &str) -> Option<&TenantEntry> {
self.by_id.get(tenant_id)
}
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
pub fn database_tenant_targets(&self) -> Vec<(String, String)> {
self.by_id
.iter()
.filter_map(|(id, entry)| {
if matches!(entry.strategy, TenantStrategy::Database) {
entry
.database_url
.as_ref()
.map(|url| (id.clone(), url.clone()))
} else {
None
}
})
.collect()
}
pub fn has_shared_rls_tenants(&self) -> bool {
self.by_id
.values()
.any(|e| matches!(e.strategy, TenantStrategy::Rls) && e.database_url.is_none())
}
pub fn rls_dedicated_db_targets(&self) -> Vec<(String, String)> {
self.by_id
.iter()
.filter_map(|(id, entry)| {
if matches!(entry.strategy, TenantStrategy::Rls) {
entry
.database_url
.as_ref()
.map(|url| (id.clone(), url.clone()))
} else {
None
}
})
.collect()
}
}
pub async fn load_registry_from_pool(pool: &Pool) -> Result<TenantRegistry, AppError> {
let q_table = qualified_sys_table("_sys_tenants");
let sql = format!(
"SELECT id, strategy, database_url FROM {} ORDER BY id",
q_table
);
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(&sql)
.fetch_all(pool)
.await?;
let forced = forced_tenant_strategy();
if let Some(s) = &forced {
let name = match s {
TenantStrategy::Database => "database",
TenantStrategy::Rls => "rls",
};
tracing::info!(
"ARCHITECT_TENANT_STRATEGY override active: all tenants run as '{}' strategy (per-tenant _sys_tenants.strategy ignored)",
name
);
}
let mut by_id = HashMap::new();
for (id, strategy_str, database_url) in rows {
let strategy = match &forced {
Some(s) => s.clone(),
None => {
if strategy_str.eq_ignore_ascii_case("schema") {
tracing::warn!(
"tenant {}: strategy 'schema' is no longer supported, skipping",
id
);
continue;
}
strategy_str.parse().map_err(|e: AppError| e)?
}
};
let database_url = if matches!(&forced, Some(TenantStrategy::Rls)) {
None
} else {
database_url.filter(|s| !s.is_empty())
};
if matches!(&strategy, TenantStrategy::Database) && database_url.is_none() {
tracing::warn!(
"tenant {}: database strategy requires database_url, skipping",
id
);
continue;
}
by_id.insert(
id,
TenantEntry {
strategy,
database_url,
},
);
}
Ok(TenantRegistry { by_id })
}
#[cfg(test)]
mod strategy_override_tests {
use super::*;
#[test]
fn strategy_parses_rls_and_database_case_insensitively() {
assert_eq!(
"rls".parse::<TenantStrategy>().unwrap(),
TenantStrategy::Rls
);
assert_eq!(
"DATABASE".parse::<TenantStrategy>().unwrap(),
TenantStrategy::Database
);
assert!("bogus".parse::<TenantStrategy>().is_err());
}
#[test]
fn forced_strategy_reads_env() {
let prev = std::env::var("ARCHITECT_TENANT_STRATEGY").ok();
std::env::remove_var("ARCHITECT_TENANT_STRATEGY");
assert!(forced_tenant_strategy().is_none(), "unset = no override");
std::env::set_var("ARCHITECT_TENANT_STRATEGY", "rls");
assert_eq!(forced_tenant_strategy(), Some(TenantStrategy::Rls));
std::env::set_var("ARCHITECT_TENANT_STRATEGY", " database ");
assert_eq!(forced_tenant_strategy(), Some(TenantStrategy::Database));
std::env::set_var("ARCHITECT_TENANT_STRATEGY", "nonsense");
assert!(
forced_tenant_strategy().is_none(),
"unrecognized value is ignored"
);
match prev {
Some(v) => std::env::set_var("ARCHITECT_TENANT_STRATEGY", v),
None => std::env::remove_var("ARCHITECT_TENANT_STRATEGY"),
}
}
}