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

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

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

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

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

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

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

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:

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

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

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