Files
orion/docs/architecture/multi-tenant.md
Samir Boulahtit e9253fbd84 refactor: rename Wizamart to Orion across entire codebase
Replace all ~1,086 occurrences of Wizamart/wizamart/WIZAMART/WizaMart
with Orion/orion/ORION across 184 files. This includes database
identifiers, email addresses, domain references, R2 bucket names,
DNS prefixes, encryption salt, Celery app name, config defaults,
Docker configs, CI configs, documentation, seed data, and templates.

Renames homepage-wizamart.html template to homepage-orion.html.
Fixes duplicate file_pattern key in api.yaml architecture rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:46:56 +01:00

610 lines
14 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 shop while sharing the same application instance and database.
**Key Concept**: One application, multiple isolated store shops, each accessible via different URLs.
**Important Distinction:**
- **Store Dashboard** (all modes): `/store/{code}/*` (singular) - Management interface for stores
- **Shop Storefront** (path-based only): `/stores/{code}/shop/*` (plural) - Customer-facing shop
- This naming distinction helps separate administrative routes from public-facing shop 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 Shop
anothershop.com → Store 2 Shop
beststore.net → Store 3 Shop
```
**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 shop 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 Shop
store2.platform.com → Store 2 Shop
store3.platform.com → Store 3 Shop
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 Shop
2 | store2 | Store Two Shop
3 | store3 | Store Three Shop
```
### 3. Path-Based Mode
**Concept**: All stores share the same domain, differentiated by URL path.
**Example**:
```
platform.com/stores/store1/shop → Store 1 Shop
platform.com/stores/store2/shop → Store 2 Shop
platform.com/stores/store3/shop → Store 3 Shop
```
**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**:
- `/stores/{code}/shop/*` - 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/store/shop` |
## Implementation Details
### Store Detection Logic
The `StoreContextMiddleware` detects stores using this priority:
```python
def detect_store(request):
host = request.headers.get("host")
# 1. Try custom domain first
store = find_by_custom_domain(host)
if store:
return store, "custom_domain"
# 2. Try subdomain
if host != settings.platform_domain:
store_code = host.split('.')[0]
store = find_by_code(store_code)
if store:
return store, "subdomain"
# 3. Try path-based
path = request.url.path
if path.startswith("/store/") or path.startswith("/stores/"):
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 Shop Routes** (Development):
```
Original: /stores/ORION/shop/products
Extracted: store_code = "ORION"
Clean: /shop/products
```
**Store Dashboard Routes** (All environments):
```
Original: /store/ORION/dashboard
Extracted: store_code = "ORION"
Clean: /dashboard
```
**Note**: The shop storefront uses `/stores/` (plural) while the store dashboard uses `/store/` (singular). This distinction helps separate customer-facing shop routes from store management routes.
**Why Clean Path?**
- FastAPI routes don't include store prefix
- Routes defined as: `@app.get("/shop/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 Shop'),
('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/stores/shop1/shop
myplatform.com/stores/shop2/shop
myplatform.com/stores/shop3/shop
```
**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/stores/shop5/shop → Store 5
myplatform.com/stores/shop6/shop → 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 /shop/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 = "/shop/products"
- Sets: context_type = SHOP
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 /shop/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 /stores/ORION/shop/products HTTP/1.1
Host: myplatform.com
```
**Processing**:
```
1. StoreContextMiddleware
- Checks: path starts with "/store/"
- Extracts: code = "ORION"
- Queries: stores WHERE code = "ORION"
- Sets: request.state.store = <Store>
- Sets: request.state.clean_path = "/shop/products"
2. FastAPI Router
- Routes registered with prefix: /stores/{store_code}/shop
- Matches: /stores/ORION/shop/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_shop_page_multi_tenant(client):
# Test subdomain routing
response = client.get(
"/shop/products",
headers={"Host": "orion.platform.com"}
)
assert "Orion" in response.text
# Test different store
response = client.get(
"/shop/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'])
```