Multi-Tenancy Guide

This guide explains the multi-tenancy architecture and how to work with tenant-isolated data. For complete technical details, see .context/global/multi-tenancy.md.

Overview

The CMS uses schema-per-tenant isolation with multi-site support:
  • Database: One master database + one schema per tenant
  • Isolation: Complete data separation at PostgreSQL schema level
  • Multi-Site: One tenant can manage multiple websites (v0.13+)
  • Headers: X-Tenant-Id (required), X-Site-Id (optional, UUID format, defaults to first active site)

Architecture

Database Structure

cms_master (database)
├── cms_tenants         # Tenant registry
├── cms_sites           # Site configuration
└── cms_users           # CMS administrators

cms_tenant_demo (schema)
├── cms_articles
├── cms_pages
├── cms_media
├── cms_categories
├── cms_tags
├── cms_menus
├── cms_menu_items
├── cms_blocks
├── cms_settings
└── ... (all tenant entities)

cms_tenant_acme (schema)
├── cms_articles        # Completely isolated from demo
├── cms_pages
└── ...

Three-Tier Isolation

1. Tenant Resolution
   ├─> Extract X-Tenant-Id header
   ├─> Load Tenant entity from cms_tenants
   └─> Validate tenant status = 'active'

2. Schema Switching
   ├─> Set PostgreSQL search_path
   └─> search_path = cms_tenant_{slug}, public

3. Doctrine Filters
   ├─> Enable tenant_filter (automatic)
   ├─> Enable site_filter (if X-Site-Id provided)
   └─> All queries automatically scoped

Working with Tenants

Headers Required

Every API request must include X-Tenant-Id:
curl https://local.api.cms/admin/articles \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: demo"
Optional X-Site-Id for multi-site (UUID of the target site):
curl https://local.api.cms/admin/articles \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: demo" \
  -H "X-Site-Id: <site-uuid>"

Creating a Tenant

# Command line
php bin/console app:tenant:create demo "Demo Tenant"

# What happens:
# 1. Create record in cms_tenants
# 2. Create PostgreSQL schema: cms_tenant_demo
# 3. Run migrations in new schema
# 4. Create default site (ID: 1)

Listing Tenants

php bin/console app:tenant:list

# Output:
# ID  Slug   Name          Status   Created
# 1   demo   Demo Tenant   active   2026-02-09
# 2   acme   Acme Corp     active   2026-02-08

Deleting a Tenant

# ⚠️ DESTRUCTIVE - deletes all tenant data
php bin/console app:tenant:delete demo

# Confirmation required
# What happens:
# 1. Set tenant status = 'deleted'
# 2. Drop PostgreSQL schema (all data lost)
# 3. Delete from cms_tenants

Multi-Site Support (v0.13+)

Concept

One tenant = Multiple websites:
Tenant: "acme"
├── Site 1: "Corporate Website" (acme-corp.com)
│   ├── Articles about products
│   └── Pages: About, Contact
└── Site 2: "Company Blog" (blog.acme.com)
    ├── Articles about industry news
    └── Pages: Team, Careers

Site Isolation

Database:
  • All tenant entities have site column (integer)
  • Unique constraints scoped per site: (slug, site)
Example:
-- Both are valid (same slug, different sites)
INSERT INTO cms_articles (slug, site) VALUES ('welcome', 1);
INSERT INTO cms_articles (slug, site) VALUES ('welcome', 2);

Working with Sites

List Sites (for current tenant):
curl https://local.api.cms/admin/sites \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: acme"
Response:
[
  {
    "id": 1,
    "tenant_site_id": 1,
    "slug": "acme-main",
    "name": "Corporate Website",
    "status": "active"
  },
  {
    "id": 2,
    "tenant_site_id": 2,
    "slug": "acme-blog",
    "name": "Company Blog",
    "status": "active"
  }
]
Filter by Site:
# Get articles for a specific site
curl https://local.api.cms/admin/articles \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: acme" \
  -H "X-Site-Id: <site-uuid>"

Security Model

Cross-Tenant Access Prevention

Automatic Enforcement:
  1. Schema switching prevents cross-tenant queries
  2. Doctrine filters add WHERE site = :site to all queries
  3. TenantSubscriber validates user belongs to tenant
Example:
// User A (tenant: demo) tries to access tenant: acme
// Request: X-Tenant-Id: acme

// TenantSubscriber checks:
if ($user->getTenantId() !== $tenant->getId()) {
    throw new AccessDeniedException('User does not belong to this tenant');
}

Query Safety

✅ SAFE (automatic filtering):
// Doctrine query
$articles = $this->articleRepository->findAll();
// SQL: SELECT * FROM cms_articles WHERE site = 1
// (site filter added automatically)
❌ UNSAFE (bypasses filters):
// Raw SQL without tenant/site filtering
$this->connection->executeQuery('SELECT * FROM cms_articles');
// ⚠️ Returns ALL articles across all sites
✅ SAFE Raw SQL:
// Always add site filter manually
$this->connection->executeQuery(
    'SELECT * FROM cms_articles WHERE site = :site',
    ['site' => $this->siteContext->getSiteId()]
);

Storage Isolation

S3/MinIO Structure

cms-media/
├── tenant_1/
│   └── media/
│       └── 2026/
│           └── 02/
│               └── uuid.jpg
├── tenant_2/
│   └── media/
│       └── 2026/
│           └── 02/
│               └── uuid.jpg
└── tenant_3/
    └── media/...
Automatic Prefix: All uploads prefixed with tenant_{id}/

Storage Quotas

Per-Tenant Quotas:
  • Default: 1 GB (1024 MB)
  • Range: 1 MB - 100 GB
  • Enforced before upload
Check Quota:
curl https://local.api.cms/admin/media/quota/info \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: demo"
Response:
{
  "quota_mb": 1024,
  "used_mb": 450.75,
  "available_mb": 573.25,
  "usage_percentage": 44.0,
  "media_count": 328
}

Development Workflow

Testing Multi-Tenancy

Create Test Tenants:
php bin/console app:tenant:create tenant1 "Test Tenant 1"
php bin/console app:tenant:create tenant2 "Test Tenant 2"
Create Users for Each Tenant:
php bin/console app:user:create \
  --tenant=tenant1 \
  --email=admin@tenant1.local \
  --password=secret \
  --role=ROLE_CMS_ADMIN

php bin/console app:user:create \
  --tenant=tenant2 \
  --email=admin@tenant2.local \
  --password=secret \
  --role=ROLE_CMS_ADMIN
Test Isolation:
# Create article in tenant1
curl -X POST https://local.api.cms/admin/articles \
  -H "Authorization: Bearer <token-tenant1>" \
  -H "X-Tenant-Id: tenant1" \
  -d '{"slug": "test", "title": {"fr": "Test"}}'

# Try to read from tenant2 (should be empty)
curl https://local.api.cms/admin/articles \
  -H "Authorization: Bearer <token-tenant2>" \
  -H "X-Tenant-Id: tenant2"
# Response: {"hydra:totalItems": 0, "hydra:member": []}

Frontend Configuration

Nuxt 3 (environment-based):
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      apiBase: process.env.API_BASE_URL || 'https://local.api.cms',
      tenantId: process.env.TENANT_ID || 'demo',
      siteId: process.env.SITE_ID || '<site-uuid>'
    }
  }
});
API Wrapper:
// composables/useApi.ts
export function useApi() {
  const config = useRuntimeConfig();

  const api = $fetch.create({
    baseURL: config.public.apiBase,
    headers: {
      'X-Tenant-Id': config.public.tenantId,
      'X-Site-Id': config.public.siteId
    }
  });

  return { api };
}

Production Considerations

Scaling Strategies

1. Schema per Tenant (Current):
  • Pros: Complete isolation, easy to understand
  • Cons: Limited to ~1000 tenants per database
  • Best for: B2B SaaS with hundreds of customers
2. Schema per Tier (Future):
  • Small tenants: Shared schema with tenant_id column
  • Large tenants: Dedicated schemas
  • Best for: 10,000+ tenants with varying sizes

Backup Strategy

Per-Tenant Backups:
# Backup single tenant
pg_dump -h localhost -U cms_user \
  --schema=cms_tenant_demo \
  cms_master > tenant_demo_backup.sql

# Restore tenant
psql -h localhost -U cms_user cms_master < tenant_demo_backup.sql
Full Backup:
# Backup all tenants + master
pg_dump -h localhost -U cms_user cms_master > full_backup.sql

Monitoring

Key Metrics:
  • Active tenants count
  • Database schema size per tenant
  • Storage usage per tenant
  • API requests per tenant
  • Cache hit rate per tenant
Alerting:
  • Storage quota > 90%
  • Schema size > 10 GB
  • Unusual cross-tenant access attempts

Troubleshooting

”Tenant not found”

Causes:
  • X-Tenant-Id header missing
  • Tenant slug incorrect
  • Tenant status = ‘suspended’ or ‘deleted’
Solutions:
  • Check header: X-Tenant-Id: <slug>
  • Verify tenant exists: php bin/console app:tenant:list
  • Check tenant status in cms_tenants table

”User does not belong to this tenant”

Causes:
  • User’s tenant_id doesn’t match X-Tenant-Id
  • User created in wrong tenant
Solutions:
  • Check user’s tenant_id in cms_users table
  • Recreate user in correct tenant
  • Update user’s tenant_id (⚠️ use with caution)

Schema not found

Causes:
  • Tenant schema not created
  • Migrations not run
Solutions:
# Recreate schema
php bin/console doctrine:schema:create --em=tenant

# Run migrations
php bin/console doctrine:migrations:migrate

Further Reading