Files
orion/docs/architecture/multi-tenant.md

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 Wizamart platform supports **three deployment modes** for multi-tenancy, allowing each vendor to have their own isolated shop while sharing the same application instance and database.
**Key Concept**: One application, multiple isolated vendor shops, each accessible via different URLs.
**Important Distinction:**
- **Vendor Dashboard** (all modes): `/vendor/{code}/*` (singular) - Management interface for vendors
- **Shop Storefront** (path-based only): `/vendors/{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 vendor has their own domain pointing to the platform.
**Example**:
```
customdomain1.com → Vendor 1 Shop
anothershop.com → Vendor 2 Shop
beststore.net → Vendor 3 Shop
```
**How it works**:
1. Vendor registers a custom domain
2. Domain's DNS is configured to point to the platform
3. Platform detects vendor by matching domain in database
4. Vendor's shop is displayed with their theme/branding
**Use Case**: Professional vendors who want their own branded domain
**Configuration**:
```python
# Database: vendor_domains table
vendor_id | domain
----------|------------------
1 | customdomain1.com
2 | anothershop.com
3 | beststore.net
```
### 2. Subdomain Mode
**Concept**: Each vendor gets a subdomain of the platform domain.
**Example**:
```
vendor1.platform.com → Vendor 1 Shop
vendor2.platform.com → Vendor 2 Shop
vendor3.platform.com → Vendor 3 Shop
admin.platform.com → Admin Interface
```
**How it works**:
1. Vendor is assigned a unique code (e.g., "vendor1")
2. Subdomain is automatically available: `{code}.platform.com`
3. Platform detects vendor from subdomain prefix
4. No DNS configuration needed by vendor
**Use Case**: Easy setup, no custom domain required
**Configuration**:
```python
# Vendors table
id | code | name
---|---------|----------
1 | vendor1 | Vendor One Shop
2 | vendor2 | Vendor Two Shop
3 | vendor3 | Vendor Three Shop
```
### 3. Path-Based Mode
**Concept**: All vendors share the same domain, differentiated by URL path.
**Example**:
```
platform.com/vendors/vendor1/shop → Vendor 1 Shop
platform.com/vendors/vendor2/shop → Vendor 2 Shop
platform.com/vendors/vendor3/shop → Vendor 3 Shop
```
**How it works**:
1. URL path includes vendor code
2. Platform extracts vendor code from path
3. Path is rewritten for routing
4. All vendors on same domain
**Use Case**: Development and testing environments only
**Path Patterns**:
- `/vendors/{code}/shop/*` - Storefront pages (correct pattern)
- `/vendor/{code}/*` - Vendor 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/vendor/shop` |
## Implementation Details
### Vendor Detection Logic
The `VendorContextMiddleware` detects vendors using this priority:
```python
def detect_vendor(request):
host = request.headers.get("host")
# 1. Try custom domain first
vendor = find_by_custom_domain(host)
if vendor:
return vendor, "custom_domain"
# 2. Try subdomain
if host != settings.platform_domain:
vendor_code = host.split('.')[0]
vendor = find_by_code(vendor_code)
if vendor:
return vendor, "subdomain"
# 3. Try path-based
path = request.url.path
if path.startswith("/vendor/") or path.startswith("/vendors/"):
vendor_code = extract_code_from_path(path)
vendor = find_by_code(vendor_code)
if vendor:
return vendor, "path_based"
return None, None
```
### Path Extraction
For path-based routing, clean paths are extracted:
**Path-Based Shop Routes** (Development):
```
Original: /vendors/WIZAMART/shop/products
Extracted: vendor_code = "WIZAMART"
Clean: /shop/products
```
**Vendor Dashboard Routes** (All environments):
```
Original: /vendor/WIZAMART/dashboard
Extracted: vendor_code = "WIZAMART"
Clean: /dashboard
```
**Note**: The shop storefront uses `/vendors/` (plural) while the vendor dashboard uses `/vendor/` (singular). This distinction helps separate customer-facing shop routes from vendor management routes.
**Why Clean Path?**
- FastAPI routes don't include vendor prefix
- Routes defined as: `@app.get("/shop/products")`
- Path must be rewritten to match routes
## Database Schema
### Vendors Table
```sql
CREATE TABLE vendors (
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()
);
```
### Vendor Domains Table
```sql
CREATE TABLE vendor_domains (
id SERIAL PRIMARY KEY,
vendor_id INTEGER REFERENCES vendors(id),
domain VARCHAR(255) UNIQUE NOT NULL, -- Custom domain
is_verified BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
```
**Example Data**:
```sql
-- Vendors
INSERT INTO vendors (code, name) VALUES
('wizamart', 'Wizamart Shop'),
('techstore', 'Tech Store'),
('fashionhub', 'Fashion Hub');
-- Custom Domains
INSERT INTO vendor_domains (vendor_id, domain) VALUES
(1, 'wizamart.com'),
(2, 'mytechstore.net');
```
## Deployment Scenarios
### Scenario 1: Small Platform (Path-Based)
**Setup**:
- Single domain: `myplatform.com`
- All vendors use path-based routing
- Single SSL certificate
- Simplest infrastructure
**URLs**:
```
myplatform.com/admin
myplatform.com/vendors/shop1/shop
myplatform.com/vendors/shop2/shop
myplatform.com/vendors/shop3/shop
```
**Infrastructure**:
```
[Internet] → [Single Server] → [PostgreSQL]
myplatform.com
```
### Scenario 2: Medium Platform (Subdomain)
**Setup**:
- Main domain: `myplatform.com`
- Vendors get subdomains automatically
- Wildcard SSL certificate (`*.myplatform.com`)
- Better branding for vendors
**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 vendors get custom domains
- Regular vendors use subdomains
- Free tier uses path-based
**URLs**:
```
# Custom domains (premium)
customdomain.com → Vendor 1
anotherdomain.com → Vendor 2
# Subdomains (standard)
shop3.myplatform.com → Vendor 3
shop4.myplatform.com → Vendor 4
# Path-based (free tier)
myplatform.com/vendors/shop5/shop → Vendor 5
myplatform.com/vendors/shop6/shop → Vendor 6
```
**Infrastructure**:
```
[CDN/Load Balancer]
|
+-----------------+------------------+
| | |
[App Server 1] [App Server 2] [App Server 3]
| | |
+-----------------+------------------+
|
[PostgreSQL Cluster]
```
## DNS Configuration
### For Custom Domains
**Vendor 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 `vendor_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 `vendor_id`:
```python
# Example: Get products for current vendor
products = db.query(Product).filter(
Product.vendor_id == request.state.vendor_id
).all()
# Example: Create order for vendor
order = Order(
vendor_id=request.state.vendor_id,
customer_id=customer_id,
# ... other fields
)
```
**Critical**: ALWAYS filter by `vendor_id` in queries!
### Theme Isolation
Each vendor has independent theme settings:
```python
# Vendor 1 theme
{
"primary_color": "#3B82F6",
"logo_url": "/static/vendors/vendor1/logo.png"
}
# Vendor 2 theme
{
"primary_color": "#10B981",
"logo_url": "/static/vendors/vendor2/logo.png"
}
```
### File Storage Isolation
Vendor files stored in separate directories:
```
static/
└── vendors/
├── vendor1/
│ ├── logo.png
│ ├── favicon.ico
│ └── products/
│ ├── product1.jpg
│ └── product2.jpg
└── vendor2/
├── 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. VendorContextMiddleware
- Checks: domain = "customdomain.com"
- Queries: vendor_domains WHERE domain = "customdomain.com"
- Finds: vendor_id = 1
- Sets: request.state.vendor = <Vendor 1>
2. ContextDetectionMiddleware
- Analyzes: path = "/shop/products"
- Sets: context_type = SHOP
3. ThemeContextMiddleware
- Queries: vendor_themes WHERE vendor_id = 1
- Sets: request.state.theme = {...}
4. Route Handler
- Queries: products WHERE vendor_id = 1
- Renders: template with Vendor 1 theme
```
### Example 2: Subdomain Request
**Request**:
```http
GET /shop/products HTTP/1.1
Host: wizamart.myplatform.com
```
**Processing**:
```
1. VendorContextMiddleware
- Checks: host != "myplatform.com"
- Extracts: subdomain = "wizamart"
- Queries: vendors WHERE code = "wizamart"
- Sets: request.state.vendor = <Vendor "wizamart">
2-4. Same as Example 1
```
### Example 3: Path-Based Request
**Request**:
```http
GET /vendors/WIZAMART/shop/products HTTP/1.1
Host: myplatform.com
```
**Processing**:
```
1. VendorContextMiddleware
- Checks: path starts with "/vendor/"
- Extracts: code = "WIZAMART"
- Queries: vendors WHERE code = "WIZAMART"
- Sets: request.state.vendor = <Vendor>
- Sets: request.state.clean_path = "/shop/products"
2. FastAPI Router
- Routes registered with prefix: /vendors/{vendor_code}/shop
- Matches: /vendors/WIZAMART/shop/products
- vendor_code path parameter = "WIZAMART"
3-4. Same as previous examples (Context, Theme middleware)
```
## Testing Multi-Tenancy
### Unit Tests
```python
def test_vendor_detection_custom_domain():
request = MockRequest(host="customdomain.com")
middleware = VendorContextMiddleware()
vendor, mode = middleware.detect_vendor(request, db)
assert vendor.code == "vendor1"
assert mode == "custom_domain"
def test_vendor_detection_subdomain():
request = MockRequest(host="shop1.platform.com")
middleware = VendorContextMiddleware()
vendor, mode = middleware.detect_vendor(request, db)
assert vendor.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": "wizamart.platform.com"}
)
assert "Wizamart" in response.text
# Test different vendor
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 vendor
products = db.query(Product).filter(
Product.vendor_id == request.state.vendor_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 vendor codes:
```python
# Sanitize vendor code
vendor_code = vendor_code.lower().strip()
# Validate format
if not re.match(r'^[a-z0-9-]{3,50}$', vendor_code):
raise ValidationError("Invalid vendor code")
```
## Performance Optimization
### 1. Cache Vendor Lookups
```python
# Cache vendor by domain/code
@lru_cache(maxsize=1000)
def get_vendor_by_code(code: str):
return db.query(Vendor).filter(Vendor.code == code).first()
```
### 2. Database Indexes
```sql
-- Index for fast lookups
CREATE INDEX idx_vendors_code ON vendors(code);
CREATE INDEX idx_vendor_domains_domain ON vendor_domains(domain);
CREATE INDEX idx_products_vendor_id ON products(vendor_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 vendor 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 vendor_id to existing table
op.add_column('products',
sa.Column('vendor_id', sa.Integer(), nullable=True)
)
# Set default vendor for existing data
op.execute("UPDATE products SET vendor_id = 1 WHERE vendor_id IS NULL")
# Make non-nullable
op.alter_column('products', 'vendor_id', nullable=False)
# Add foreign key
op.create_foreign_key(
'fk_products_vendor',
'products', 'vendors',
['vendor_id'], ['id']
)
# Add index
op.create_index('idx_products_vendor_id', 'products', ['vendor_id'])
```