Skip to main content

helios_persistence/strategy/
mod.rs

1//! Multitenancy strategy implementations.
2//!
3//! This module provides three tenancy isolation strategies:
4//!
5//! - [`SharedSchemaStrategy`] - All tenants in one schema with tenant_id column
6//! - [`SchemaPerTenantStrategy`] - Separate database schema per tenant
7//! - [`DatabasePerTenantStrategy`] - Separate database per tenant
8//!
9//! # Choosing a Strategy
10//!
11//! | Strategy | Isolation | Performance | Scalability | Complexity |
12//! |----------|-----------|-------------|-------------|------------|
13//! | Shared Schema | Low | High | Medium | Low |
14//! | Schema-per-Tenant | Medium | Medium | Medium | Medium |
15//! | Database-per-Tenant | High | Low | High | High |
16//!
17//! ## Shared Schema
18//!
19//! Best for:
20//! - Many small tenants with similar data patterns
21//! - Simple deployment and maintenance
22//! - Cost-sensitive environments
23//!
24//! Considerations:
25//! - All tenants share resources (connections, indexes)
26//! - Requires careful index design (tenant_id should be leading)
27//! - Row-Level Security can add additional protection
28//!
29//! ## Schema-per-Tenant
30//!
31//! Best for:
32//! - Medium number of tenants
33//! - Need for logical isolation
34//! - Tenant-specific customizations
35//!
36//! Considerations:
37//! - PostgreSQL-specific (uses schemas)
38//! - Shared connection pool with search_path switching
39//! - Simpler backup/restore per tenant
40//!
41//! ## Database-per-Tenant
42//!
43//! Best for:
44//! - Enterprise customers requiring complete isolation
45//! - Regulatory requirements (data residency)
46//! - Tenants with very different usage patterns
47//!
48//! Considerations:
49//! - Highest resource usage (connection pools per tenant)
50//! - Most complex operations (migrations across databases)
51//! - Best data isolation and portability
52//!
53//! # Example
54//!
55//! ```
56//! use helios_persistence::strategy::{
57//!     TenancyStrategy, SharedSchemaConfig, SchemaPerTenantConfig, DatabasePerTenantConfig
58//! };
59//!
60//! // Shared schema (simplest)
61//! let shared = TenancyStrategy::SharedSchema(SharedSchemaConfig {
62//!     use_row_level_security: true,
63//!     tenant_column: "tenant_id".to_string(),
64//!     ..Default::default()
65//! });
66//!
67//! // Schema per tenant (PostgreSQL)
68//! let schema_per = TenancyStrategy::SchemaPerTenant(SchemaPerTenantConfig {
69//!     schema_prefix: "tenant_".to_string(),
70//!     shared_schema: "shared".to_string(),
71//!     auto_create_schema: true,
72//!     ..Default::default()
73//! });
74//!
75//! // Database per tenant (maximum isolation)
76//! let db_per = TenancyStrategy::DatabasePerTenant(DatabasePerTenantConfig {
77//!     connection_template: "postgres://user:pass@{host}/{tenant}_db".to_string(),
78//!     pool_per_tenant: true,
79//!     max_pools: Some(100),
80//!     ..Default::default()
81//! });
82//! ```
83
84mod database_per_tenant;
85mod schema_per_tenant;
86mod shared_schema;
87
88pub use database_per_tenant::{DatabasePerTenantConfig, DatabasePerTenantStrategy};
89pub use schema_per_tenant::{SchemaPerTenantConfig, SchemaPerTenantStrategy};
90pub use shared_schema::{SharedSchemaConfig, SharedSchemaStrategy};
91
92use std::fmt;
93
94use serde::{Deserialize, Serialize};
95
96use crate::tenant::TenantId;
97
98/// The tenancy strategy configuration.
99///
100/// This enum defines how tenant isolation is implemented at the database level.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(tag = "type", rename_all = "snake_case")]
103pub enum TenancyStrategy {
104    /// All tenants share the same schema with a tenant_id column.
105    SharedSchema(SharedSchemaConfig),
106
107    /// Each tenant has a separate database schema.
108    SchemaPerTenant(SchemaPerTenantConfig),
109
110    /// Each tenant has a separate database.
111    DatabasePerTenant(DatabasePerTenantConfig),
112}
113
114impl Default for TenancyStrategy {
115    fn default() -> Self {
116        TenancyStrategy::SharedSchema(SharedSchemaConfig::default())
117    }
118}
119
120impl fmt::Display for TenancyStrategy {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            TenancyStrategy::SharedSchema(_) => write!(f, "shared-schema"),
124            TenancyStrategy::SchemaPerTenant(_) => write!(f, "schema-per-tenant"),
125            TenancyStrategy::DatabasePerTenant(_) => write!(f, "database-per-tenant"),
126        }
127    }
128}
129
130impl TenancyStrategy {
131    /// Returns the isolation level of this strategy.
132    pub fn isolation_level(&self) -> IsolationLevel {
133        match self {
134            TenancyStrategy::SharedSchema(_) => IsolationLevel::Logical,
135            TenancyStrategy::SchemaPerTenant(_) => IsolationLevel::Schema,
136            TenancyStrategy::DatabasePerTenant(_) => IsolationLevel::Physical,
137        }
138    }
139
140    /// Returns true if this strategy uses a shared connection pool.
141    pub fn uses_shared_pool(&self) -> bool {
142        match self {
143            TenancyStrategy::SharedSchema(_) => true,
144            TenancyStrategy::SchemaPerTenant(_) => true,
145            TenancyStrategy::DatabasePerTenant(config) => !config.pool_per_tenant,
146        }
147    }
148}
149
150/// Level of tenant isolation.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum IsolationLevel {
153    /// Logical isolation via tenant_id column.
154    Logical,
155    /// Schema-level isolation.
156    Schema,
157    /// Physical isolation via separate databases.
158    Physical,
159}
160
161impl fmt::Display for IsolationLevel {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            IsolationLevel::Logical => write!(f, "logical"),
165            IsolationLevel::Schema => write!(f, "schema"),
166            IsolationLevel::Physical => write!(f, "physical"),
167        }
168    }
169}
170
171/// Trait for tenant resolution in different strategies.
172///
173/// Implementations convert tenant IDs to database-specific identifiers
174/// (schema names, database names, connection strings, etc.).
175pub trait TenantResolver: Send + Sync {
176    /// Resolves a tenant ID to the appropriate database identifier.
177    fn resolve(&self, tenant_id: &TenantId) -> TenantResolution;
178
179    /// Validates that a tenant ID is valid for this strategy.
180    fn validate(&self, tenant_id: &TenantId) -> Result<(), TenantValidationError>;
181
182    /// Returns the system tenant resolution.
183    fn system_tenant(&self) -> TenantResolution;
184}
185
186/// Result of tenant resolution.
187#[derive(Debug, Clone)]
188pub enum TenantResolution {
189    /// Use shared schema with tenant_id filter.
190    SharedSchema {
191        /// The tenant ID to filter by.
192        tenant_id: String,
193    },
194
195    /// Use a specific schema.
196    Schema {
197        /// The schema name to use.
198        schema_name: String,
199    },
200
201    /// Use a specific database.
202    Database {
203        /// The connection string or database name.
204        connection: String,
205    },
206}
207
208/// Error when validating a tenant ID.
209#[derive(Debug, Clone)]
210pub struct TenantValidationError {
211    /// The invalid tenant ID.
212    pub tenant_id: String,
213    /// The reason for validation failure.
214    pub reason: String,
215}
216
217impl fmt::Display for TenantValidationError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(f, "invalid tenant '{}': {}", self.tenant_id, self.reason)
220    }
221}
222
223impl std::error::Error for TenantValidationError {}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_tenancy_strategy_display() {
231        let shared = TenancyStrategy::SharedSchema(SharedSchemaConfig::default());
232        assert_eq!(shared.to_string(), "shared-schema");
233
234        let schema = TenancyStrategy::SchemaPerTenant(SchemaPerTenantConfig::default());
235        assert_eq!(schema.to_string(), "schema-per-tenant");
236
237        let db = TenancyStrategy::DatabasePerTenant(DatabasePerTenantConfig::default());
238        assert_eq!(db.to_string(), "database-per-tenant");
239    }
240
241    #[test]
242    fn test_isolation_level() {
243        let shared = TenancyStrategy::SharedSchema(SharedSchemaConfig::default());
244        assert_eq!(shared.isolation_level(), IsolationLevel::Logical);
245
246        let schema = TenancyStrategy::SchemaPerTenant(SchemaPerTenantConfig::default());
247        assert_eq!(schema.isolation_level(), IsolationLevel::Schema);
248
249        let db = TenancyStrategy::DatabasePerTenant(DatabasePerTenantConfig::default());
250        assert_eq!(db.isolation_level(), IsolationLevel::Physical);
251    }
252
253    #[test]
254    fn test_uses_shared_pool() {
255        let shared = TenancyStrategy::SharedSchema(SharedSchemaConfig::default());
256        assert!(shared.uses_shared_pool());
257
258        let db_shared_pool = TenancyStrategy::DatabasePerTenant(DatabasePerTenantConfig {
259            pool_per_tenant: false,
260            ..Default::default()
261        });
262        assert!(db_shared_pool.uses_shared_pool());
263
264        let db_per_pool = TenancyStrategy::DatabasePerTenant(DatabasePerTenantConfig {
265            pool_per_tenant: true,
266            ..Default::default()
267        });
268        assert!(!db_per_pool.uses_shared_pool());
269    }
270
271    #[test]
272    fn test_tenant_validation_error_display() {
273        let err = TenantValidationError {
274            tenant_id: "bad-tenant".to_string(),
275            reason: "contains invalid characters".to_string(),
276        };
277        assert!(err.to_string().contains("bad-tenant"));
278        assert!(err.to_string().contains("invalid characters"));
279    }
280}