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

14 KiB

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.

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:

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

# 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/vendor/vendor1/shop  → Vendor 1 Shop
platform.com/vendor/vendor2/shop  → Vendor 2 Shop
platform.com/vendors/vendor3/shop → Vendor 3 Shop (alternative)

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: Simplest deployment, single domain certificate

Path Patterns:

  • /vendor/{code}/shop/* - Storefront pages
  • /vendor/{code}/api/* - API endpoints (if needed)
  • /vendors/{code}/shop/* - Alternative pattern

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:

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:

Example 1: Single vendor prefix

Original:  /vendor/WIZAMART/shop/products
Extracted: vendor_code = "WIZAMART"
Clean:     /shop/products

Example 2: Plural vendors prefix

Original:  /vendors/WIZAMART/shop/products
Extracted: vendor_code = "WIZAMART"
Clean:     /shop/products

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

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

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:

-- 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/vendor/shop1/shop
myplatform.com/vendor/shop2/shop
myplatform.com/vendor/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/vendor/shop5/shop → Vendor 5
myplatform.com/vendor/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:

# Wildcard certificate
*.myplatform.com
myplatform.com

Tenant Isolation

Data Isolation

Every database query is scoped to vendor_id:

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

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

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:

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:

GET /vendor/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. PathRewriteMiddleware
   - Rewrites: request.scope['path'] = "/shop/products"

3-4. Same as previous examples

Testing Multi-Tenancy

Unit Tests

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

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:

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

# 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

# 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

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

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