# 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'])
```
