Some checks failed
The setting `settings.platform_domain` (the global/main domain like "wizard.lu") was easily confused with `platform.domain` (per-platform domain like "rewardflow.lu"). Renamed to `settings.main_domain` / `MAIN_DOMAIN` env var across the entire codebase. Also updated docs to reflect the refactored store detection logic with `is_platform_domain` / `is_subdomain_of_platform` guards. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
621 lines
15 KiB
Markdown
621 lines
15 KiB
Markdown
# Multi-Tenant System
|
|
|
|
Complete guide to the multi-tenant architecture supporting custom domains, subdomains, and path-based routing.
|
|
|
|
## Overview
|
|
|
|
The Orion platform supports **three deployment modes** for multi-tenancy, allowing each store to have their own isolated storefront while sharing the same application instance and database.
|
|
|
|
**Key Concept**: One application, multiple isolated store storefronts, each accessible via different URLs.
|
|
|
|
**Important Distinction:**
|
|
- **Store Dashboard** (all modes): `/store/{code}/*` (singular) - Management interface for stores
|
|
- **Storefront** (path-based only): `/storefront/{code}/*` - Customer-facing storefront
|
|
- This naming distinction helps separate administrative routes from public-facing storefront routes
|
|
|
|
## The Three Routing Modes
|
|
|
|
### 1. Custom Domain Mode
|
|
|
|
**Concept**: Each store has their own domain pointing to the platform.
|
|
|
|
**Example**:
|
|
```
|
|
customdomain1.com → Store 1 Storefront
|
|
anothershop.com → Store 2 Storefront
|
|
beststore.net → Store 3 Storefront
|
|
```
|
|
|
|
**How it works**:
|
|
1. Store registers a custom domain
|
|
2. Domain's DNS is configured to point to the platform
|
|
3. Platform detects store by matching domain in database
|
|
4. Store's storefront is displayed with their theme/branding
|
|
|
|
**Use Case**: Professional stores who want their own branded domain
|
|
|
|
**Configuration**:
|
|
```python
|
|
# Database: store_domains table
|
|
store_id | domain
|
|
----------|------------------
|
|
1 | customdomain1.com
|
|
2 | anothershop.com
|
|
3 | beststore.net
|
|
```
|
|
|
|
### 2. Subdomain Mode
|
|
|
|
**Concept**: Each store gets a subdomain of the platform domain.
|
|
|
|
**Example**:
|
|
```
|
|
store1.platform.com → Store 1 Storefront
|
|
store2.platform.com → Store 2 Storefront
|
|
store3.platform.com → Store 3 Storefront
|
|
admin.platform.com → Admin Interface
|
|
```
|
|
|
|
**How it works**:
|
|
1. Store is assigned a unique code (e.g., "store1")
|
|
2. Subdomain is automatically available: `{code}.platform.com`
|
|
3. Platform detects store from subdomain prefix
|
|
4. No DNS configuration needed by store
|
|
|
|
**Use Case**: Easy setup, no custom domain required
|
|
|
|
**Configuration**:
|
|
```python
|
|
# Stores table
|
|
id | code | name
|
|
---|---------|----------
|
|
1 | store1 | Store One Storefront
|
|
2 | store2 | Store Two Storefront
|
|
3 | store3 | Store Three Storefront
|
|
```
|
|
|
|
### 3. Path-Based Mode
|
|
|
|
**Concept**: All stores share the same domain, differentiated by URL path.
|
|
|
|
**Example**:
|
|
```
|
|
platform.com/storefront/store1 → Store 1 Storefront
|
|
platform.com/storefront/store2 → Store 2 Storefront
|
|
platform.com/storefront/store3 → Store 3 Storefront
|
|
```
|
|
|
|
**How it works**:
|
|
1. URL path includes store code
|
|
2. Platform extracts store code from path
|
|
3. Path is rewritten for routing
|
|
4. All stores on same domain
|
|
|
|
**Use Case**: Development and testing environments only
|
|
|
|
**Path Patterns**:
|
|
- `/storefront/{code}/*` - Storefront pages (correct pattern)
|
|
- `/store/{code}/*` - Store dashboard pages (different context)
|
|
|
|
## Routing Mode Comparison
|
|
|
|
| Feature | Custom Domain | Subdomain | Path-Based |
|
|
|---------|---------------|-----------|------------|
|
|
| **Professionalism** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
|
|
| **Setup Complexity** | High (DNS required) | Low (automatic) | Very Low |
|
|
| **SSL Complexity** | Medium (wildcard or per-domain) | Low (wildcard SSL) | Very Low (single cert) |
|
|
| **SEO Benefits** | Best (own domain) | Good | Limited |
|
|
| **Cost** | High (domain + SSL) | Low (wildcard SSL) | Lowest |
|
|
| **Isolation** | Best (separate domain) | Good | Good |
|
|
| **URL Appearance** | `shop.com` | `shop.platform.com` | `platform.com/storefront/store` |
|
|
|
|
## Implementation Details
|
|
|
|
### Store Detection Logic
|
|
|
|
The `StoreContextMiddleware` detects stores via `_detect_store_from_host_and_path()`.
|
|
Before trying the three detection methods, two guards determine which methods apply:
|
|
|
|
- **`is_platform_domain`**: `host == platform.domain` (e.g., `rewardflow.lu` itself) — skips Methods 1 & 2
|
|
- **`is_subdomain_of_platform`**: host is a subdomain of `platform.domain` (e.g., `acme.rewardflow.lu`) — skips Method 1
|
|
|
|
```python
|
|
def detect_store(host, path, platform):
|
|
platform_own_domain = platform.domain # e.g. "rewardflow.lu"
|
|
is_platform_domain = (host == platform_own_domain)
|
|
is_subdomain_of_platform = (
|
|
host != platform_own_domain
|
|
and host.endswith(f".{platform_own_domain}")
|
|
)
|
|
|
|
# 1. Custom domain — skipped when host is platform domain or subdomain of it
|
|
if not is_platform_domain and not is_subdomain_of_platform:
|
|
main_domain = settings.main_domain # e.g. "wizard.lu"
|
|
if host != main_domain and not host.endswith(f".{main_domain}"):
|
|
store = find_by_custom_domain(host)
|
|
if store:
|
|
return store, "custom_domain"
|
|
|
|
# 2. Subdomain — skipped when host IS the platform domain
|
|
if not is_platform_domain and "." in host:
|
|
subdomain = host.split('.')[0]
|
|
store = find_by_code(subdomain)
|
|
if store:
|
|
return store, "subdomain"
|
|
|
|
# 3. Path-based — always runs as fallback
|
|
if path.startswith("/store/") or path.startswith("/storefront/"):
|
|
store_code = extract_code_from_path(path)
|
|
store = find_by_code(store_code)
|
|
if store:
|
|
return store, "path_based"
|
|
|
|
return None, None
|
|
```
|
|
|
|
### Path Extraction
|
|
|
|
For path-based routing, clean paths are extracted:
|
|
|
|
**Path-Based Storefront Routes** (Development):
|
|
```
|
|
Original: /storefront/ORION/products
|
|
Extracted: store_code = "ORION"
|
|
Clean: /storefront/products
|
|
```
|
|
|
|
**Store Dashboard Routes** (All environments):
|
|
```
|
|
Original: /store/ORION/dashboard
|
|
Extracted: store_code = "ORION"
|
|
Clean: /dashboard
|
|
```
|
|
|
|
**Note**: The storefront uses `/storefront/` while the store dashboard uses `/store/` (singular). This distinction helps separate customer-facing storefront routes from store management routes.
|
|
|
|
**Why Clean Path?**
|
|
- FastAPI routes don't include store prefix
|
|
- Routes defined as: `@app.get("/storefront/products")`
|
|
- Path must be rewritten to match routes
|
|
|
|
## Database Schema
|
|
|
|
### Stores Table
|
|
|
|
```sql
|
|
CREATE TABLE stores (
|
|
id SERIAL PRIMARY KEY,
|
|
code VARCHAR(50) UNIQUE NOT NULL, -- For subdomain/path routing
|
|
name VARCHAR(255) NOT NULL,
|
|
is_active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
### Store Domains Table
|
|
|
|
```sql
|
|
CREATE TABLE store_domains (
|
|
id SERIAL PRIMARY KEY,
|
|
store_id INTEGER REFERENCES stores(id),
|
|
domain VARCHAR(255) UNIQUE NOT NULL, -- Custom domain
|
|
is_verified BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
**Example Data**:
|
|
```sql
|
|
-- Stores
|
|
INSERT INTO stores (code, name) VALUES
|
|
('orion', 'Orion Storefront'),
|
|
('techstore', 'Tech Store'),
|
|
('fashionhub', 'Fashion Hub');
|
|
|
|
-- Custom Domains
|
|
INSERT INTO store_domains (store_id, domain) VALUES
|
|
(1, 'orion.lu'),
|
|
(2, 'mytechstore.net');
|
|
```
|
|
|
|
## Deployment Scenarios
|
|
|
|
### Scenario 1: Small Platform (Path-Based)
|
|
|
|
**Setup**:
|
|
- Single domain: `myplatform.com`
|
|
- All stores use path-based routing
|
|
- Single SSL certificate
|
|
- Simplest infrastructure
|
|
|
|
**URLs**:
|
|
```
|
|
myplatform.com/admin
|
|
myplatform.com/storefront/shop1
|
|
myplatform.com/storefront/shop2
|
|
myplatform.com/storefront/shop3
|
|
```
|
|
|
|
**Infrastructure**:
|
|
```
|
|
[Internet] → [Single Server] → [PostgreSQL]
|
|
myplatform.com
|
|
```
|
|
|
|
### Scenario 2: Medium Platform (Subdomain)
|
|
|
|
**Setup**:
|
|
- Main domain: `myplatform.com`
|
|
- Stores get subdomains automatically
|
|
- Wildcard SSL certificate (`*.myplatform.com`)
|
|
- Better branding for stores
|
|
|
|
**URLs**:
|
|
```
|
|
admin.myplatform.com
|
|
shop1.myplatform.com
|
|
shop2.myplatform.com
|
|
shop3.myplatform.com
|
|
```
|
|
|
|
**Infrastructure**:
|
|
```
|
|
[Internet] → [Load Balancer] → [App Servers] → [PostgreSQL]
|
|
*.myplatform.com
|
|
```
|
|
|
|
### Scenario 3: Large Platform (Mixed Mode)
|
|
|
|
**Setup**:
|
|
- Supports all three modes
|
|
- Premium stores get custom domains
|
|
- Regular stores use subdomains
|
|
- Free tier uses path-based
|
|
|
|
**URLs**:
|
|
```
|
|
# Custom domains (premium)
|
|
customdomain.com → Store 1
|
|
anotherdomain.com → Store 2
|
|
|
|
# Subdomains (standard)
|
|
shop3.myplatform.com → Store 3
|
|
shop4.myplatform.com → Store 4
|
|
|
|
# Path-based (free tier)
|
|
myplatform.com/storefront/shop5 → Store 5
|
|
myplatform.com/storefront/shop6 → Store 6
|
|
```
|
|
|
|
**Infrastructure**:
|
|
```
|
|
[CDN/Load Balancer]
|
|
|
|
|
+-----------------+------------------+
|
|
| | |
|
|
[App Server 1] [App Server 2] [App Server 3]
|
|
| | |
|
|
+-----------------+------------------+
|
|
|
|
|
[PostgreSQL Cluster]
|
|
```
|
|
|
|
## DNS Configuration
|
|
|
|
### For Custom Domains
|
|
|
|
**Store Side**:
|
|
```
|
|
# DNS A Record
|
|
customdomain.com. A 203.0.113.10 (platform IP)
|
|
|
|
# Or CNAME
|
|
customdomain.com. CNAME myplatform.com.
|
|
```
|
|
|
|
**Platform Side**:
|
|
- Add domain to `store_domains` table
|
|
- Generate SSL certificate (Let's Encrypt)
|
|
- Verify domain ownership
|
|
|
|
### For Subdomains
|
|
|
|
**Platform Side**:
|
|
```
|
|
# Wildcard DNS
|
|
*.myplatform.com. A 203.0.113.10
|
|
|
|
# Or individual subdomains
|
|
shop1.myplatform.com. A 203.0.113.10
|
|
shop2.myplatform.com. A 203.0.113.10
|
|
```
|
|
|
|
**SSL Certificate**:
|
|
```bash
|
|
# Wildcard certificate
|
|
*.myplatform.com
|
|
myplatform.com
|
|
```
|
|
|
|
## Tenant Isolation
|
|
|
|
### Data Isolation
|
|
|
|
Every database query is scoped to `store_id`:
|
|
|
|
```python
|
|
# Example: Get products for current store
|
|
products = db.query(Product).filter(
|
|
Product.store_id == request.state.store_id
|
|
).all()
|
|
|
|
# Example: Create order for store
|
|
order = Order(
|
|
store_id=request.state.store_id,
|
|
customer_id=customer_id,
|
|
# ... other fields
|
|
)
|
|
```
|
|
|
|
**Critical**: ALWAYS filter by `store_id` in queries!
|
|
|
|
### Theme Isolation
|
|
|
|
Each store has independent theme settings:
|
|
|
|
```python
|
|
# Store 1 theme
|
|
{
|
|
"primary_color": "#3B82F6",
|
|
"logo_url": "/static/stores/store1/logo.png"
|
|
}
|
|
|
|
# Store 2 theme
|
|
{
|
|
"primary_color": "#10B981",
|
|
"logo_url": "/static/stores/store2/logo.png"
|
|
}
|
|
```
|
|
|
|
### File Storage Isolation
|
|
|
|
Store files stored in separate directories:
|
|
|
|
```
|
|
static/
|
|
└── stores/
|
|
├── store1/
|
|
│ ├── logo.png
|
|
│ ├── favicon.ico
|
|
│ └── products/
|
|
│ ├── product1.jpg
|
|
│ └── product2.jpg
|
|
└── store2/
|
|
├── logo.png
|
|
└── products/
|
|
└── product1.jpg
|
|
```
|
|
|
|
## Request Examples
|
|
|
|
### Example 1: Custom Domain Request
|
|
|
|
**Request**:
|
|
```http
|
|
GET /storefront/products HTTP/1.1
|
|
Host: customdomain.com
|
|
```
|
|
|
|
**Processing**:
|
|
```
|
|
1. StoreContextMiddleware
|
|
- Checks: domain = "customdomain.com"
|
|
- Queries: store_domains WHERE domain = "customdomain.com"
|
|
- Finds: store_id = 1
|
|
- Sets: request.state.store = <Store 1>
|
|
|
|
2. ContextDetectionMiddleware
|
|
- Analyzes: path = "/storefront/products"
|
|
- Sets: context_type = STOREFRONT
|
|
|
|
3. ThemeContextMiddleware
|
|
- Queries: store_themes WHERE store_id = 1
|
|
- Sets: request.state.theme = {...}
|
|
|
|
4. Route Handler
|
|
- Queries: products WHERE store_id = 1
|
|
- Renders: template with Store 1 theme
|
|
```
|
|
|
|
### Example 2: Subdomain Request
|
|
|
|
**Request**:
|
|
```http
|
|
GET /storefront/products HTTP/1.1
|
|
Host: orion.myplatform.com
|
|
```
|
|
|
|
**Processing**:
|
|
```
|
|
1. StoreContextMiddleware
|
|
- Checks: host != "myplatform.com"
|
|
- Extracts: subdomain = "orion"
|
|
- Queries: stores WHERE code = "orion"
|
|
- Sets: request.state.store = <Store "orion">
|
|
|
|
2-4. Same as Example 1
|
|
```
|
|
|
|
### Example 3: Path-Based Request
|
|
|
|
**Request**:
|
|
```http
|
|
GET /storefront/ORION/products HTTP/1.1
|
|
Host: myplatform.com
|
|
```
|
|
|
|
**Processing**:
|
|
```
|
|
1. StoreContextMiddleware
|
|
- Checks: path starts with "/storefront/"
|
|
- Extracts: code = "ORION"
|
|
- Queries: stores WHERE code = "ORION"
|
|
- Sets: request.state.store = <Store>
|
|
- Sets: request.state.clean_path = "/storefront/products"
|
|
|
|
2. FastAPI Router
|
|
- Routes registered with prefix: /storefront/{store_code}
|
|
- Matches: /storefront/ORION/products
|
|
- store_code path parameter = "ORION"
|
|
|
|
3-4. Same as previous examples (Context, Theme middleware)
|
|
```
|
|
|
|
## Testing Multi-Tenancy
|
|
|
|
### Unit Tests
|
|
|
|
```python
|
|
def test_store_detection_custom_domain():
|
|
request = MockRequest(host="customdomain.com")
|
|
middleware = StoreContextMiddleware()
|
|
|
|
store, mode = middleware.detect_store(request, db)
|
|
|
|
assert store.code == "store1"
|
|
assert mode == "custom_domain"
|
|
|
|
def test_store_detection_subdomain():
|
|
request = MockRequest(host="shop1.platform.com")
|
|
middleware = StoreContextMiddleware()
|
|
|
|
store, mode = middleware.detect_store(request, db)
|
|
|
|
assert store.code == "shop1"
|
|
assert mode == "subdomain"
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
```python
|
|
def test_storefront_page_multi_tenant(client):
|
|
# Test subdomain routing
|
|
response = client.get(
|
|
"/storefront/products",
|
|
headers={"Host": "orion.platform.com"}
|
|
)
|
|
assert "Orion" in response.text
|
|
|
|
# Test different store
|
|
response = client.get(
|
|
"/storefront/products",
|
|
headers={"Host": "techstore.platform.com"}
|
|
)
|
|
assert "Tech Store" in response.text
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### 1. Tenant Isolation
|
|
|
|
**Always scope queries**:
|
|
```python
|
|
# ✅ Good - Scoped to store
|
|
products = db.query(Product).filter(
|
|
Product.store_id == request.state.store_id
|
|
).all()
|
|
|
|
# ❌ Bad - Not scoped, leaks data across tenants!
|
|
products = db.query(Product).all()
|
|
```
|
|
|
|
### 2. Domain Verification
|
|
|
|
Before activating custom domain:
|
|
1. Verify DNS points to platform
|
|
2. Check domain ownership (email/file verification)
|
|
3. Generate SSL certificate
|
|
4. Mark domain as verified
|
|
|
|
### 3. Input Validation
|
|
|
|
Validate store codes:
|
|
```python
|
|
# Sanitize store code
|
|
store_code = store_code.lower().strip()
|
|
|
|
# Validate format
|
|
if not re.match(r'^[a-z0-9-]{3,50}$', store_code):
|
|
raise ValidationError("Invalid store code")
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### 1. Cache Store Lookups
|
|
|
|
```python
|
|
# Cache store by domain/code
|
|
@lru_cache(maxsize=1000)
|
|
def get_store_by_code(code: str):
|
|
return db.query(Store).filter(Store.code == code).first()
|
|
```
|
|
|
|
### 2. Database Indexes
|
|
|
|
```sql
|
|
-- Index for fast lookups
|
|
CREATE INDEX idx_stores_code ON stores(code);
|
|
CREATE INDEX idx_store_domains_domain ON store_domains(domain);
|
|
CREATE INDEX idx_products_store_id ON products(store_id);
|
|
```
|
|
|
|
### 3. Connection Pooling
|
|
|
|
Ensure database connection pool is properly configured:
|
|
|
|
```python
|
|
# sqlalchemy engine
|
|
engine = create_engine(
|
|
DATABASE_URL,
|
|
pool_size=20,
|
|
max_overflow=40,
|
|
pool_pre_ping=True
|
|
)
|
|
```
|
|
|
|
## Related Documentation
|
|
|
|
- [Middleware Stack](middleware.md) - How store detection works
|
|
- [Request Flow](request-flow.md) - Complete request journey
|
|
- [Architecture Overview](overview.md) - System architecture
|
|
- [Authentication & RBAC](auth-rbac.md) - Multi-tenant security
|
|
|
|
## Migration Guide
|
|
|
|
### Adding Multi-Tenancy to Existing Tables
|
|
|
|
```python
|
|
# Alembic migration
|
|
def upgrade():
|
|
# Add store_id to existing table
|
|
op.add_column('products',
|
|
sa.Column('store_id', sa.Integer(), nullable=True)
|
|
)
|
|
|
|
# Set default store for existing data
|
|
op.execute("UPDATE products SET store_id = 1 WHERE store_id IS NULL")
|
|
|
|
# Make non-nullable
|
|
op.alter_column('products', 'store_id', nullable=False)
|
|
|
|
# Add foreign key
|
|
op.create_foreign_key(
|
|
'fk_products_store',
|
|
'products', 'stores',
|
|
['store_id'], ['id']
|
|
)
|
|
|
|
# Add index
|
|
op.create_index('idx_products_store_id', 'products', ['store_id'])
|
|
```
|