Files
orion/docs/architecture/multi-tenant.md
Samir Boulahtit c2c0e3c740
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
refactor: rename platform_domain → main_domain to avoid confusion with platform.domain
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>
2026-03-14 04:45:28 +01:00

15 KiB

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:

# 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:

# 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
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

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

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:

-- 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:

# Wildcard certificate
*.myplatform.com
myplatform.com

Tenant Isolation

Data Isolation

Every database query is scoped to store_id:

# 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:

# 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:

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:

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:

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

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

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:

# ✅ 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:

# 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

# 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

-- 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:

# sqlalchemy engine
engine = create_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=40,
    pool_pre_ping=True
)

Migration Guide

Adding Multi-Tenancy to Existing Tables

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