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>
610 lines
14 KiB
Markdown
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'])
|
|
```
|