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