use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TenantId(pub String);
impl TenantId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TenantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for TenantId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for TenantId {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsolationStrategy {
Database {
database_name: String,
},
Schema {
database_name: String,
schema_name: String,
},
Row {
database_name: String,
tenant_column: String,
},
Branch {
branch_name: String,
},
}
impl IsolationStrategy {
pub fn database(name: impl Into<String>) -> Self {
Self::Database {
database_name: name.into(),
}
}
pub fn schema(database: impl Into<String>, schema: impl Into<String>) -> Self {
Self::Schema {
database_name: database.into(),
schema_name: schema.into(),
}
}
pub fn row(database: impl Into<String>, column: impl Into<String>) -> Self {
Self::Row {
database_name: database.into(),
tenant_column: column.into(),
}
}
pub fn branch(name: impl Into<String>) -> Self {
Self::Branch {
branch_name: name.into(),
}
}
pub fn database_name(&self) -> Option<&str> {
match self {
Self::Database { database_name } => Some(database_name),
Self::Schema { database_name, .. } => Some(database_name),
Self::Row { database_name, .. } => Some(database_name),
Self::Branch { .. } => None,
}
}
pub fn schema_name(&self) -> Option<&str> {
match self {
Self::Schema { schema_name, .. } => Some(schema_name),
_ => None,
}
}
pub fn tenant_column(&self) -> Option<&str> {
match self {
Self::Row { tenant_column, .. } => Some(tenant_column),
_ => None,
}
}
pub fn branch_name(&self) -> Option<&str> {
match self {
Self::Branch { branch_name } => Some(branch_name),
_ => None,
}
}
pub fn requires_query_transform(&self) -> bool {
matches!(self, Self::Row { .. })
}
pub fn requires_connection_routing(&self) -> bool {
matches!(self, Self::Database { .. } | Self::Branch { .. })
}
pub fn strategy_name(&self) -> &'static str {
match self {
Self::Database { .. } => "database",
Self::Schema { .. } => "schema",
Self::Row { .. } => "row",
Self::Branch { .. } => "branch",
}
}
}
#[derive(Debug, Clone)]
pub struct TenantRateLimits {
pub qps_limit: u32,
pub max_connections: u32,
pub max_query_duration: Duration,
pub max_result_size: u64,
pub max_rows_per_query: u64,
pub burst_multiplier: f32,
}
impl Default for TenantRateLimits {
fn default() -> Self {
Self {
qps_limit: 100,
max_connections: 10,
max_query_duration: Duration::from_secs(60),
max_result_size: 100 * 1024 * 1024, max_rows_per_query: 100_000,
burst_multiplier: 2.0,
}
}
}
impl TenantRateLimits {
pub fn with_qps(qps: u32) -> Self {
Self {
qps_limit: qps,
..Default::default()
}
}
pub fn qps_limit(mut self, limit: u32) -> Self {
self.qps_limit = limit;
self
}
pub fn max_connections(mut self, limit: u32) -> Self {
self.max_connections = limit;
self
}
pub fn max_query_duration(mut self, duration: Duration) -> Self {
self.max_query_duration = duration;
self
}
pub fn burst_multiplier(mut self, multiplier: f32) -> Self {
self.burst_multiplier = multiplier;
self
}
}
#[derive(Debug, Clone)]
pub struct TenantPoolConfig {
pub max_connections: u32,
pub min_idle: u32,
pub idle_timeout: Duration,
pub max_lifetime: Duration,
pub acquire_timeout: Duration,
pub dedicated_pool: bool,
}
impl Default for TenantPoolConfig {
fn default() -> Self {
Self {
max_connections: 10,
min_idle: 1,
idle_timeout: Duration::from_secs(600),
max_lifetime: Duration::from_secs(3600),
acquire_timeout: Duration::from_secs(5),
dedicated_pool: false,
}
}
}
impl TenantPoolConfig {
pub fn with_max_connections(max: u32) -> Self {
Self {
max_connections: max,
..Default::default()
}
}
pub fn dedicated(mut self) -> Self {
self.dedicated_pool = true;
self
}
pub fn min_idle(mut self, min: u32) -> Self {
self.min_idle = min;
self
}
pub fn idle_timeout(mut self, timeout: Duration) -> Self {
self.idle_timeout = timeout;
self
}
}
#[derive(Debug, Clone)]
pub struct TenantPermissions {
pub allowed_operations: Vec<String>,
pub blocked_tables: Vec<String>,
pub read_only: bool,
pub allow_ddl: bool,
pub allow_explain: bool,
pub allow_system_access: bool,
pub max_tables_per_query: u32,
}
impl Default for TenantPermissions {
fn default() -> Self {
Self {
allowed_operations: vec![
"SELECT".to_string(),
"INSERT".to_string(),
"UPDATE".to_string(),
"DELETE".to_string(),
],
blocked_tables: vec![],
read_only: false,
allow_ddl: false,
allow_explain: true,
allow_system_access: false,
max_tables_per_query: 10,
}
}
}
impl TenantPermissions {
pub fn read_only() -> Self {
Self {
allowed_operations: vec!["SELECT".to_string()],
read_only: true,
..Default::default()
}
}
pub fn full_access() -> Self {
Self {
allowed_operations: vec![
"SELECT".to_string(),
"INSERT".to_string(),
"UPDATE".to_string(),
"DELETE".to_string(),
"CREATE".to_string(),
"ALTER".to_string(),
"DROP".to_string(),
],
allow_ddl: true,
allow_system_access: true,
..Default::default()
}
}
pub fn is_operation_allowed(&self, operation: &str) -> bool {
self.allowed_operations
.iter()
.any(|op| op.eq_ignore_ascii_case(operation))
}
pub fn is_table_allowed(&self, table: &str) -> bool {
!self.blocked_tables.iter().any(|t| t.eq_ignore_ascii_case(table))
}
}
#[derive(Debug, Clone)]
pub struct TenantAiConfig {
pub knowledge_base: Option<String>,
pub embedding_model: String,
pub retrieval_limit: u32,
pub daily_token_budget: Option<u64>,
pub agent_workspace_enabled: bool,
pub max_concurrent_agents: u32,
}
impl Default for TenantAiConfig {
fn default() -> Self {
Self {
knowledge_base: None,
embedding_model: "default".to_string(),
retrieval_limit: 10,
daily_token_budget: None,
agent_workspace_enabled: true,
max_concurrent_agents: 5,
}
}
}
#[derive(Debug, Clone)]
pub struct TenantConfig {
pub id: TenantId,
pub name: String,
pub isolation: IsolationStrategy,
pub rate_limits: TenantRateLimits,
pub pool: TenantPoolConfig,
pub permissions: TenantPermissions,
pub ai_config: TenantAiConfig,
pub metadata: HashMap<String, String>,
pub enabled: bool,
pub created_at: std::time::SystemTime,
}
impl TenantConfig {
pub fn builder() -> TenantConfigBuilder {
TenantConfigBuilder::new()
}
pub fn new(id: impl Into<TenantId>, isolation: IsolationStrategy) -> Self {
Self {
id: id.into(),
name: String::new(),
isolation,
rate_limits: TenantRateLimits::default(),
pool: TenantPoolConfig::default(),
permissions: TenantPermissions::default(),
ai_config: TenantAiConfig::default(),
metadata: HashMap::new(),
enabled: true,
created_at: std::time::SystemTime::now(),
}
}
pub fn is_healthy(&self) -> bool {
self.enabled
}
pub fn effective_max_connections(&self) -> u32 {
self.pool.max_connections.min(self.rate_limits.max_connections)
}
}
#[derive(Debug, Default)]
pub struct TenantConfigBuilder {
id: Option<TenantId>,
name: Option<String>,
isolation: Option<IsolationStrategy>,
rate_limits: Option<TenantRateLimits>,
pool: Option<TenantPoolConfig>,
permissions: Option<TenantPermissions>,
ai_config: Option<TenantAiConfig>,
metadata: HashMap<String, String>,
enabled: bool,
}
impl TenantConfigBuilder {
pub fn new() -> Self {
Self {
enabled: true,
..Default::default()
}
}
pub fn id(mut self, id: impl Into<TenantId>) -> Self {
self.id = Some(id.into());
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn isolation(mut self, strategy: IsolationStrategy) -> Self {
self.isolation = Some(strategy);
self
}
pub fn database_isolation(self, database: impl Into<String>) -> Self {
self.isolation(IsolationStrategy::database(database))
}
pub fn schema_isolation(self, database: impl Into<String>, schema: impl Into<String>) -> Self {
self.isolation(IsolationStrategy::schema(database, schema))
}
pub fn row_isolation(self, database: impl Into<String>, column: impl Into<String>) -> Self {
self.isolation(IsolationStrategy::row(database, column))
}
pub fn branch_isolation(self, branch: impl Into<String>) -> Self {
self.isolation(IsolationStrategy::branch(branch))
}
pub fn rate_limits(mut self, limits: TenantRateLimits) -> Self {
self.rate_limits = Some(limits);
self
}
pub fn qps_limit(mut self, limit: u32) -> Self {
let mut limits = self.rate_limits.take().unwrap_or_default();
limits.qps_limit = limit;
self.rate_limits = Some(limits);
self
}
pub fn max_connections(mut self, max: u32) -> Self {
let mut pool = self.pool.take().unwrap_or_default();
pool.max_connections = max;
self.pool = Some(pool);
let mut limits = self.rate_limits.take().unwrap_or_default();
limits.max_connections = max;
self.rate_limits = Some(limits);
self
}
pub fn pool(mut self, config: TenantPoolConfig) -> Self {
self.pool = Some(config);
self
}
pub fn permissions(mut self, perms: TenantPermissions) -> Self {
self.permissions = Some(perms);
self
}
pub fn read_only(self) -> Self {
self.permissions(TenantPermissions::read_only())
}
pub fn ai_config(mut self, config: TenantAiConfig) -> Self {
self.ai_config = Some(config);
self
}
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn build(self) -> TenantConfig {
TenantConfig {
id: self.id.expect("tenant id is required"),
name: self.name.unwrap_or_default(),
isolation: self.isolation.expect("isolation strategy is required"),
rate_limits: self.rate_limits.unwrap_or_default(),
pool: self.pool.unwrap_or_default(),
permissions: self.permissions.unwrap_or_default(),
ai_config: self.ai_config.unwrap_or_default(),
metadata: self.metadata,
enabled: self.enabled,
created_at: std::time::SystemTime::now(),
}
}
}
#[derive(Debug, Clone)]
pub enum IdentificationMethod {
Header {
header_name: String,
},
UsernamePrefix {
separator: char,
},
JwtClaim {
claim_name: String,
issuer: Option<String>,
},
DatabaseName,
SqlContext {
variable_name: String,
},
}
impl Default for IdentificationMethod {
fn default() -> Self {
Self::Header {
header_name: "X-Tenant-Id".to_string(),
}
}
}
impl IdentificationMethod {
pub fn header(name: impl Into<String>) -> Self {
Self::Header {
header_name: name.into(),
}
}
pub fn username_prefix(separator: char) -> Self {
Self::UsernamePrefix { separator }
}
pub fn jwt_claim(claim: impl Into<String>) -> Self {
Self::JwtClaim {
claim_name: claim.into(),
issuer: None,
}
}
pub fn database_name() -> Self {
Self::DatabaseName
}
pub fn sql_context(variable: impl Into<String>) -> Self {
Self::SqlContext {
variable_name: variable.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct MultiTenancyConfig {
pub enabled: bool,
pub identification: IdentificationMethod,
pub default_config: TenantConfig,
pub allow_unknown_tenants: bool,
pub auto_create_tenants: bool,
pub max_tenants: u32,
pub cross_tenant_analytics: bool,
pub admin_user_pattern: Option<String>,
}
impl Default for MultiTenancyConfig {
fn default() -> Self {
Self {
enabled: false,
identification: IdentificationMethod::default(),
default_config: TenantConfig::new(
TenantId::new("default"),
IsolationStrategy::schema("public", "public"),
),
allow_unknown_tenants: false,
auto_create_tenants: false,
max_tenants: 1000,
cross_tenant_analytics: false,
admin_user_pattern: None,
}
}
}
impl MultiTenancyConfig {
pub fn enabled() -> Self {
Self {
enabled: true,
..Default::default()
}
}
pub fn with_identification(mut self, method: IdentificationMethod) -> Self {
self.identification = method;
self
}
pub fn with_default_config(mut self, config: TenantConfig) -> Self {
self.default_config = config;
self
}
pub fn allow_unknown(mut self) -> Self {
self.allow_unknown_tenants = true;
self
}
pub fn auto_create(mut self) -> Self {
self.auto_create_tenants = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tenant_id() {
let id = TenantId::new("test_tenant");
assert_eq!(id.as_str(), "test_tenant");
assert_eq!(id.to_string(), "test_tenant");
let id2: TenantId = "another".into();
assert_eq!(id2.as_str(), "another");
}
#[test]
fn test_isolation_strategy() {
let db = IsolationStrategy::database("mydb");
assert_eq!(db.database_name(), Some("mydb"));
assert_eq!(db.strategy_name(), "database");
assert!(db.requires_connection_routing());
assert!(!db.requires_query_transform());
let schema = IsolationStrategy::schema("mydb", "myschema");
assert_eq!(schema.database_name(), Some("mydb"));
assert_eq!(schema.schema_name(), Some("myschema"));
assert_eq!(schema.strategy_name(), "schema");
let row = IsolationStrategy::row("mydb", "tenant_id");
assert_eq!(row.tenant_column(), Some("tenant_id"));
assert!(row.requires_query_transform());
let branch = IsolationStrategy::branch("tenant_branch");
assert_eq!(branch.branch_name(), Some("tenant_branch"));
assert!(branch.requires_connection_routing());
}
#[test]
fn test_tenant_config_builder() {
let config = TenantConfig::builder()
.id("tenant_a")
.name("Acme Corp")
.schema_isolation("shared_db", "tenant_a")
.max_connections(50)
.qps_limit(1000)
.metadata("tier", "enterprise")
.build();
assert_eq!(config.id.as_str(), "tenant_a");
assert_eq!(config.name, "Acme Corp");
assert_eq!(config.pool.max_connections, 50);
assert_eq!(config.rate_limits.qps_limit, 1000);
assert_eq!(config.metadata.get("tier"), Some(&"enterprise".to_string()));
}
#[test]
fn test_tenant_permissions() {
let default = TenantPermissions::default();
assert!(default.is_operation_allowed("SELECT"));
assert!(default.is_operation_allowed("select"));
assert!(!default.is_operation_allowed("CREATE"));
assert!(!default.allow_ddl);
let read_only = TenantPermissions::read_only();
assert!(read_only.is_operation_allowed("SELECT"));
assert!(!read_only.is_operation_allowed("INSERT"));
assert!(read_only.read_only);
let full = TenantPermissions::full_access();
assert!(full.is_operation_allowed("CREATE"));
assert!(full.allow_ddl);
}
#[test]
fn test_identification_methods() {
let header = IdentificationMethod::header("X-Tenant-Id");
assert!(matches!(header, IdentificationMethod::Header { header_name } if header_name == "X-Tenant-Id"));
let prefix = IdentificationMethod::username_prefix('.');
assert!(matches!(prefix, IdentificationMethod::UsernamePrefix { separator: '.' }));
let jwt = IdentificationMethod::jwt_claim("tenant_id");
assert!(matches!(jwt, IdentificationMethod::JwtClaim { claim_name, .. } if claim_name == "tenant_id"));
}
#[test]
fn test_multi_tenancy_config() {
let config = MultiTenancyConfig::enabled()
.with_identification(IdentificationMethod::header("X-Org-Id"))
.allow_unknown()
.auto_create();
assert!(config.enabled);
assert!(config.allow_unknown_tenants);
assert!(config.auto_create_tenants);
}
}