Multi-Tenancy Architecture
Architecture and implementation details of SveltyCMS Multi-Tenancy system.
On this page
This page is how a tenant is stored and queried. Who is allowed to cross a tenant boundary is Multi-tenant security.
SveltyCMS supports a robust multi-tenancy architecture allowing a single instance to serve multiple isolated tenants with flexible resource sharing capabilities.
tenantId: null] -->|manages all| System UserA[Tenant A User] -->|scoped to| TenantA UserB[Tenant B User] -->|scoped to| TenantB
Core Concepts
Tenant Entity Model
SveltyCMS treats tenants as first-class entities stored in the database. Each tenant record controls access, status, and resource limits.
interface Tenant {
_id: string; // UUID
name: string;
status: "active" | "suspended" | "pending";
ownerId?: string; // Link to owner User
plan: "free" | "pro" | "enterprise";
// Resource Quotas
quota: {
maxUsers: number;
maxStorageBytes: number;
maxCollections: number;
};
// Current Usage (Updated via Telemetry/Hooks)
usage: {
usersCount: number;
storageBytes: number;
collectionsCount: number;
};
createdAt: string;
updatedAt: string;
}
Tenant Identification
Tenants are identified via:
-
Hostname:
tenant1.example.com-> Resolves to Tenant ID. -
Session Cookie:
__Host-demo_tenant_idon HTTPS /demo_tenant_idon HTTP (Demo Mode) orsvelty_tenant(Standard). Uses__Host-prefix per RFC 6265bis for subdomain isolation on secure connections. -
API Header:
X-Tenant-ID(for API integration). -
Registration: New users can register without a token (if
MULTI_TENANTandDEMOmode are enabled) to create a brand new isolated tenant. In Demo Mode, the registration UI is further simplified by hiding the token field entirely.
Resolver backstop: when the request-scoped tenant was not resolved (for example a warm turbo-auth fast-path hit, or Demo Mode’s per-visitor cookie not being present on an authenticated request), the API dispatcher falls back to the authenticated user’s own tenantId before invoking a namespace. This keeps fail-closed namespaces (the withTenant guard used by tokens, etc.) from rejecting a legitimately authenticated request as TENANT_REQUIRED, without weakening isolation — a user is only ever scoped to their own tenant, and requests with neither a tenant nor an owner stay rejected.
Global Administrator Access: The system maintains a concept of a Global Administrator (the first user created during setup). This user:
- Has no
tenantId(tenantId: null, operating globally). - Is explicitly protected from automated cleanup processes (like Demo Mode cleanup).
- Can manage all aspects of the system across all tenants.
Data Isolation Strategies
SveltyCMS supports two primary data isolation strategies that can be mixed and matched based on your requirements:
1. Tenant-Specific Resources (Full Isolation)
Resources with a tenantId field are isolated to a specific tenant. This is the default for most user-generated content.
2. Global Resources (Shared Across Tenants)
Resources without a tenantId (or with tenantId: null) are shared across all tenants. This is ideal for:
- System-wide roles and permissions
- Shared content templates
- Global configuration settings
- Common collections accessible to all tenants
Resource Isolation Patterns
Roles and Permissions
Roles can be configured as either global or tenant-specific:
// Global Role (shared across all tenants)
interface GlobalRole {
_id: string;
name: string;
permissions: string[];
tenantId?: undefined; // No tenantId = global
}
// Tenant-Specific Role
interface TenantRole {
_id: string;
name: string;
permissions: string[];
tenantId: string; // Specific tenant ID
}
Use Cases:
- Global Roles: System-wide roles like “Super Admin”, “Content Manager”, “Viewer” that apply across all tenants
- Tenant-Specific Roles: Custom roles per tenant like “Acme Corp Editor”, “Client A Reviewer”
Database Operations:
// Get all roles (global + tenant-specific)
await dbAdapter.auth.getAllRoles(tenantId);
// Get only global roles
await dbAdapter.auth.getAllRoles(undefined);
// Get tenant-specific roles
await dbAdapter.auth.getAllRoles("tenant-123");
Collections
Collections can be configured as global or tenant-specific:
// Global Collection (accessible to all tenants)
interface GlobalCollection {
_id: string;
name: string;
nodeType: "collection";
tenantId?: undefined; // No tenantId = global
}
// Tenant-Specific Collection
interface TenantCollection {
_id: string;
name: string;
nodeType: "collection";
tenantId: string; // Specific tenant ID
}
Use Cases:
- Global Collections: Shared resources like “Products”, “Categories”, “Tags” that all tenants can access
- Tenant-Specific Collections: Isolated data like “Orders”, “Customers”, “Internal Documents”
Content Entries: All entries within a collection inherit the collection’s isolation strategy:
interface CollectionEntry {
_id: string;
status?: "published" | "draft" | "archived";
tenantId?: string; // Matches parent collection's tenantId
createdBy?: string;
updatedBy?: string;
}
System Settings
System settings support an explicit isGlobal flag:
interface SystemSetting {
key: string;
value: unknown;
isGlobal?: boolean; // true = shared across tenants
tenantId?: string; // undefined if isGlobal is true
}
Use Cases:
- Global Settings: System-wide configuration like “SMTP Server”, “Default Theme”, “API Rate Limits”
- Tenant Settings: Per-tenant customization like “Company Logo”, “Branding Colors”, “Feature Flags”
Implementation Details
Authentication Hook
The handleAuthentication hook (src/hooks/handleAuthentication.ts) is the entry point for tenant resolution:
- It checks the
MULTI_TENANTsetting. - It resolves the
tenantIdfrom the request (hostname or cookie). - It sets
event.locals.tenantId. - It validates that the authenticated user belongs to the resolved tenant.
Database Adapter
The DatabaseAdapter interface supports optional tenantId for most operations, enabling flexible resource isolation:
interface IAuthAdapter {
// Roles
getAllRoles(tenantId?: string): Promise<Role[]>;
getRoleById(roleId: string, tenantId?: string): Promise<DatabaseResult<Role | null>>;
createRole(role: Role): Promise<DatabaseResult<Role>>;
updateRole(
roleId: string,
roleData: Partial<Role>,
tenantId?: string,
): Promise<DatabaseResult<Role>>;
deleteRole(roleId: string, tenantId?: string): Promise<DatabaseResult<void>>;
// Users
getUserById(user_id: string, tenantId?: string): Promise<DatabaseResult<User | null>>;
getUserByEmail(criteria: {
email: string;
tenantId?: string;
}): Promise<DatabaseResult<User | null>>;
// ...
}
Query Behavior:
tenantIdprovided: Returns only resources for that specific tenanttenantIdundefined/null: Returns global resources (notenantIdfield)- Both: Some operations may return both global and tenant-specific resources (e.g., roles)
Data Isolation Enforcement
Data isolation is enforced strictly via the safeQuery utility and Database Adapter layers:
-
Strict Query Isolation (
safeQuery):- All database operations are wrapped in
safeQuery(query, tenantId). - Mechanism: Automatically injects
{ tenantId: '...' }into every filter. - Protection: Throws an error if
tenantIdis missing in Multi-Tenant mode (unless explicitlyglobal). - Prevents accidental “find all” queries from leaking cross-tenant data.
// Example in MongoDB Adapter const secureFilter = safeQuery({ status: "active" }, currentTenantId); // Result: { status: 'active', tenantId: 'tenant-123' } - All database operations are wrapped in
Explicit Global Scope (System Services)
In specific cases where the system needs to perform operations on global resources (e.g., plugins, system settings), you must explicitly use tenantId: null. This signifies an intentional global operation:
// Internal system maintenance operating on global data
const plugins = await dbAdapter.crud.findMany(
"plugins",
{},
{
tenantId: null, // Explicit global scope
},
);
There is no longer a sudo: true backdoor to bypass tenant isolation. Every query must either be explicitly scoped to a tenantId or intentionally declared global with null. Attempting to omit the tenant context will result in a hard Security Violation.
-
Database Adapter Level:
- Operations without a
tenantIddefault to Global (if allowed) or throw errors. deleteManyandupdateManyare scoped strictly to the tenant.
- Operations without a
-
Storage Level: Media files are stored in tenant-specific folders
- Pattern:
uploads/{tenantId}/...for tenant-specific files - Pattern:
uploads/global/...for shared assets
- Pattern:
-
API Level: Endpoints validate
tenantIdfromevent.locals.tenantId -
Compilation Level: Collection schemas are compiled with
tenantIdembedded- The compilation system (
src/utils/compilation/) automatically injectstenantIdinto compiled schemas - When compiling collections, pass
tenantIdinCompileOptions:await compile({ tenantId: "tenant-123", // Tenant-specific collection // or tenantId: null, // Global collection // or tenantId: undefined, // No multi-tenancy (default) }); - Compiled files include tenant metadata in header comments for debugging:
// WARNING: Generated file. Do not edit. // HASH: abc123def456 // TENANT_ID: tenant-123
- The compilation system (
Directory Structure
MULTI_TENANT: true"] subgraph TenantDir["config/{tenantId}/"] TC["collections/ └── Products.ts └── Orders.ts"] end subgraph GlobalDir["config/global/"] GC["collections/ └── Categories.ts └── Tags.ts"] end subgraph CompiledTenant[".compiledCollections/{tenantId}/"] CTC["Products.js Orders.js"] end subgraph CompiledGlobal[".compiledCollections/global/"] CGC["Categories.js Tags.js"] end subgraph MediaTenant["mediaFolder/{tenantId}/"] MT["{hash}/original/file.jpg"] end subgraph MediaGlobal["mediaFolder/global/"] MG["{hash}/original/file.jpg"] end CFG --> TenantDir CFG --> GlobalDir CFG --> PRIV COMP --> CompiledTenant COMP --> CompiledGlobal MEDIA --> MediaTenant MEDIA --> MediaGlobal end
Multi-tenant mode uses a tenant-based directory structure for better organization. The config/private.ts file is the bootstrap source for the MULTI_TENANT flag.
Benefits:
- All tenant resources in one directory
- Easy tenant backup/migration
- Clear ownership boundaries
- Scalable for additional tenant resources (themes, plugins, etc.)
Legacy Support:
- Single-tenant mode uses
config/collections/(backward compatible) - When multi-tenancy is enabled, collections are automatically migrated from
config/collections/→config/{tenantId}/collections/viamigrateToMultiTenant() - When multi-tenancy is disabled, collections can be migrated back via
migrateToSingleTenant() - Compilation engine uses smart path resolution: if the tenant path has no
.tsfiles, it falls back to flatconfig/collections/automatically
Auto-Migration on Toggle
When MULTI_TENANT changes in config/private.ts, the collections-migration.server.ts module handles the transition:
Single → Multi:
config/collections/pages.ts ──[migrateToMultiTenant("primary")]──► config/primary/collections/pages.ts
Multi → Single:
config/primary/collections/pages.ts ──[migrateToSingleTenant("primary")]──► config/collections/pages.ts
During compilation, the engine detects empty tenant directories and falls back gracefully:
// compile.ts — smart path resolution
const entries = await fs.readdir(tenantCollections);
if (!hasTsFiles) {
const flatDir = getCollectionsPath(undefined);
if (flatDirHasFiles) {
// Fall back — not yet migrated
userCollections = flatDir;
}
}
Configuration
Multi-tenancy is enabled via the MULTI_TENANT private setting in config/private.ts or the database settings.
isMultiTenantEnabled() — Unified Detection
All runtime code uses isMultiTenantEnabled() from src/utils/tenant-isolation.server.ts instead of reading config/private.ts directly:
// Server-only: MULTI_TENANT is a private setting, so the read must not live in a
// client-reachable module (@utils/tenant is imported by the Yjs editor provider
// and the SDK config page). Result is memoized for 5s, fail-closed on error.
export function isMultiTenantEnabled(): boolean {
try {
// Merged private settings — bootstrap file plus DB-side private overrides
return getPrivateSettingSync("MULTI_TENANT") === true;
} catch {
return false;
}
}
This means:
- Fresh install: File is the only source (DB not seeded yet) → file wins
- Runtime toggle: DB setting overrides file → immediate, no restart
- Migration UI: Updates both → immediate effect + persists across restarts
CRUD Tenant Guard
(default, safe)"] REJECT_MODE["reject — throw if tenantId missing
(strict)"] BYPASS_MODE["bypass — skip all checks
(system operations)"] end
All database operations pass through crud-tenant-guard.ts, a transparent proxy that auto-injects tenantId into every query:
// Wired once at boot in db.ts
adapter.crud = createTenantGuardedCrud(adapter.crud, "inject");
| Operation | Guard behavior |
|---|---|
| Read (find, findMany, etc.) | Injects tenantId into query options — WHERE tenantId = ? |
| Write (insert, update, upsert) | Injects tenantId into both data AND options |
| Delete | Injects tenantId into query options |
Bypass (withSystemScope(reason)) |
Skips enforcement — branded system capability only (scheduler, setup, tests). A forgeable bypassTenantCheck boolean is ignored. |
This protects all plugins, custom widgets, and extensions automatically — no code changes needed.
Media Storage Isolation
Media files are stored with tenant-scoped paths:
Before: mediaFolder/global/{hash}/original/file.jpg
After: mediaFolder/{tenantId}/{hash}/original/file.jpg (multi-tenant)
mediaFolder/global/{hash}/original/file.jpg (single-tenant)
The buildOriginalRelPath(hash, filename, tenantId) function accepts optional tenantId — when provided, files are isolated per tenant. The file server (/files/[...path]) resolves URLs from the path structure.
Dashboard & Plugin Safety
| Layer | Protection |
|---|---|
| Widgets | UI-only — render data from the server, never query the DB directly |
| Dashboards | Read from tenant-filtered API endpoints — locals.tenantId in every request |
| Plugin hooks | beforeSave, afterSave receive context.tenantId |
| Plugin migrations | ensureCollection() accepts { tenantId } in options |
Dashboard API endpoints use locals.tenantId for all database queries. Widgets are inherently safe because they’re just UI — they receive data from the server, never query the DB directly.
Migration UI
direction: to-multi] DECIDE -->|tenant → flat| TOSINGLE[runFullMigration
direction: to-single] subgraph Migration["Migration Steps"] TOMULTI --> COL1[migrateToMultiTenant] TOMULTI --> MED1[migrateMediaToTenant] COL1 --> PRIV1[updatePrivateConfigMode MULTI_TENANT: true] MED1 --> PRIV1 TOSINGLE --> COL2[migrateToSingleTenant] TOSINGLE --> MED2[migrateMediaToGlobal] COL2 --> PRIV2[updatePrivateConfigMode MULTI_TENANT: false] MED2 --> PRIV2 end PRIV1 --> RECOMP[Recompile with new tenant context] PRIV2 --> RECOMP RECOMP --> DONE{{✅ Migration Complete Restart recommended}}
The System Settings page (/config/system-settings) includes a Multi-Tenancy Migration panel:
- Check Structure — runs
detectFullStructure()to detect files in wrong directories - Migrate Now — button appears when migration is needed
- Runs
runFullMigration()which orchestrates:- Collection files move
- Media files move + DB path updates
config/private.tstoggle viaupdatePrivateConfigMode()- Recompilation with new tenant context
Results are displayed inline: “Migration complete: 3 collection(s) moved, 147 media file(s) moved, recompiled”
Compilation Engine Fallback
The compiler (compile.ts) handles non-migrated structures gracefully:
// Smart fallback in compile()
if (tenantPathHasNoTsFiles && flatPathHasTsFiles) {
logger.warn(`⚠️ Tenant path empty, falling back to flat config/collections/`);
logger.warn(`⚠️ Run migration to move collections`);
userCollections = flatPath; // ← Graceful fallback
}
Hash-Based Stable IDs
When a collection file is renamed, the compiler preserves its identity:
// compile.ts — hash match detection
const hashMatch = findExistingHash(manifest, sourceHash);
if (hashMatch && hashMatch.path !== currentPath) {
// File was renamed — reuse original _id
stableId = hashMatch.originalId;
}
This ensures DB records stay linked across renames — no orphaned content.
PostgreSQL Row-Level Security (RLS)
SveltyCMS supports database-level tenant isolation via PostgreSQL Row-Level Security. This provides defense-in-depth below the application layer — even if the CRUD guard proxy is bypassed (e.g., a direct query in a migration script), RLS guarantees tenant isolation at the storage level.
auto-inject tenantId] API[API Handlers] end subgraph DB["PostgreSQL Layer"] RLS["RLS Policy
tenant_id = current_setting('app.tenant_id')"] ROWS["Row-level filter applied
automatically to every query"] end API --> CRUD CRUD -->|tenantId filter| DB DB -->|"CREATE POLICY tenant_isolation
FOR ALL USING (...)"| RLS RLS -->|invisible filter| ROWS
Three methods were added to the PostgreSQL adapter (src/databases/postgresql/adapter-core.ts):
| Method | When called | Purpose |
|---|---|---|
setTenantContext(tenantId) |
Per-request (after auth) | Sets app.tenant_id via set_config(…) only on a transaction-scoped client (is_local = true) or a per-tenant dedicated pool; the shared pool is never targeted — it relies on the app-level WHERE "tenantId" = ? filter |
enforceTenantPolicy(collection, tenantId) |
Setup/migration | Runs ALTER TABLE ... ENABLE ROW LEVEL SECURITY and creates the isolation policy |
getTenantContext() |
Diagnostics | Returns the current session’s app.tenant_id setting |
How the flow works:
- Setup time — When a collection is provisioned,
enforceTenantPolicy()is called once:ALTER TABLE collection_posts ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON collection_posts FOR ALL USING (tenant_id = current_setting('app.tenant_id')::text); - Per-request — After tenant resolution,
setTenantContext(tenantId)records the active tenant and setsapp.tenant_idonly where it can: on a transaction-scoped client (is_local = true) or on the tenant’s dedicated pool. On the shared pool the call returns early — isolation there is the application-levelWHERE "tenantId" = ?filter. - Scope of RLS — the policy filters every query that runs on a connection where
app.tenant_idwas set: the transaction-scoped client or a tenant’s dedicated pool. On the shared pool the GUC is never set, so the policy has nothing to compare against — per-request isolation there is the application-levelWHERE "tenantId" = ?filter.
Note: RLS is PostgreSQL-specific. MariaDB uses
WHEREclause injection via the CRUD guard. MongoDB uses partial filter expressions. TheIDBAdapterinterface definesenforceTenantPolicy?()andgetTenantContext?()as optional methods — adapters that don’t support RLS simply don’t implement them.
Signed Media URLs
Media files can be served with time-limited HMAC-SHA256 signed URLs, preventing indefinite access to file links even if a URL is shared or leaked.
// Generate a signed URL valid for 1 hour
const url = generateSignedMediaUrl(
"tenant-abc/hash123/original/photo.jpg",
"tenant-abc",
3600000, // 1 hour TTL
);
// Result: /files/tenant-abc/hash123/original/photo.jpg?sig=abc...&exp=1700000000
Implementation (src/utils/media/signed-urls.ts):
| Function | Description |
|---|---|
generateSignedMediaUrl(filePath, tenantId?, ttlMs?) |
Signs filePath \| tenantId \| expiration with HMAC-SHA256. Returns /files/path?sig=<64-char-hex>&exp=<timestamp>. Returns empty string (fail-closed) if MEDIA_SIGNED_URL_SECRET is not configured. Default TTL: 1 hour. |
validateSignedMediaUrl(url, filePath, tenantId?) |
Extracts sig and exp from query params, checks expiration, recomputes signature, and compares using crypto.timingSafeEqual. Returns { valid: true } or { valid: false, reason }. |
Enforcement (src/routes/files/[...path]/+server.ts):
- Opt-in: Only enforced when
MEDIA_SIGNED_URL_ENABLEDistrue(set via System Settings) - Global files exempt: Files under
mediaFolder/global/remain public (no signature required) - Fail-closed: Returns
403 SIGNATURE_REQUIREDon validation failure - Lazy-loaded: The validation module is dynamically imported only when the feature is enabled — zero overhead when disabled
To enable:
- Set
MEDIA_SIGNED_URL_SECRET(private setting) — a long random string (e.g.,openssl rand -hex 32) - Set
MEDIA_SIGNED_URL_ENABLEDtotruein System Settings - All non-global file requests now require valid
?sig=and?exp=query params
Tenant Analytics Dashboard
A dashboard widget available to all tenant admins showing their own tenant’s usage stats:
| Metric | Source |
|---|---|
| Collections count | db.crud.listCollections(tenantId) |
| Active users | cms.auth.listUsers() pagination scoped by tenant |
| Media files & storage | db.crud.count("media") + file size aggregation |
| Content entries | db.crud.count() across all tenant collections |
| 24h request activity | Filtered audit log entries |
Widget (src/routes/(app)/dashboard/widgets/tenant-analytics/tenant-analytics-widget.svelte):
- Auto-discovered via
import.meta.glob("./widgets/*/*.svelte")— no registration needed - Compact mode (1 row): shows key stats inline
- Rich mode (2+ rows): 6-card grid with storage quota bar and activity counter
- 60-second auto-refresh via
BaseWidget - All data scoped to the request’s
locals.tenantId
API endpoint: GET /api/dashboard/tenant-analytics — scoped by authenticated user’s tenant context.
Tenant-Scoped API Keys
API tokens can be created with a specific tenant scope, limiting their access to a single tenant’s data:
| Scope | Behavior |
|---|---|
| Current Tenant (default) | Token authenticates with the creator’s tenant context |
| Global (all tenants) | Token has no tenant restriction (admin only) |
UI (src/routes/(app)/config/access-management/website-tokens.svelte):
- Tenant Scope dropdown in the token creation form: “Current Tenant” / “Global (all tenants)”
- Scope column in the token table: shows “Current Tenant” or a “Global” badge
- Passing
tenantId: nullin the token creation request creates a global-scoped token
Backend: The WebsiteTokensNamespace.create() already supports tenantId?: DatabaseId | null. The token auth cache (credential-auth-cache.ts) already uses tenantId for scoped lookups. No schema changes needed.
Per-Tenant Database Connection Pooling
Enterprise deployments can configure dedicated PostgreSQL connection pools per tenant, providing connection-level isolation beyond RLS:
| Method | Description |
|---|---|
getTenantPool(tenantId) |
Returns existing pool or creates one from DATABASE_URL with application_name=tenant_{id}. Pool size configurable via TENANT_DB_POOL_SIZE env var (default: 10). Bounded by MAX_TENANT_POOLS (default: 16). |
setTenantPool(tenantId, connectionUrl) |
Registers a dedicated database URL for a tenant — true database-level isolation. Dedicated DSN pools bypass cross-tenant GUC session injection. |
closeTenantPool(tenantId) |
Closes and removes a tenant’s dedicated pool. |
closeAllTenantPools() |
Cleanup during disconnect(). |
How it works:
- When
setTenantContext(tenantId)is called per-request, it stores the active tenant ID - The
getSql()method checks if that tenant has a dedicated pool in_tenantPools - If yes → routes all queries to the tenant’s private pool (no shared pool contention, separate connection limit)
- If no → the shared pool is used with the app-level
WHERE "tenantId" = ?filter;set_configis never issued there (the call returns early when the tenant has no dedicated pool) - Dedicated pools appear as
application_name = tenant_tenant-abcinpg_stat_activityfor monitoring - Bounded LRU Eviction (
MAX_TENANT_POOLS, default: 16): When pool capacity is reached, the least recently used idle pool is gracefully ended withpool.end(). Pools with active in-flight queries are never evicted.
This is optional — most deployments use the shared pool with the application-level tenantId filter. Dedicated pools are for enterprise customers needing separate connection limits and per-tenant DB load monitoring.
Per-Tenant Rate Limiting
The rate limiter (src/hooks/handle-rate-limit.ts) uses a two-tier token bucket system to ensure fair resource distribution across tenants:
| Tier | Scope | Purpose |
|---|---|---|
| Per-IP bucket | Single IP address + lane (default or commerce) |
Prevents a single client from flooding the server |
| Per-tenant bucket | All traffic for a tenant + lane | Prevents one noisy tenant from starving others |
Guest /api/commerce/* mutations use a commerce lane (tighter ceiling, isolated buckets). Exhausting cart/coupon traffic does not 429 admin /api/collections. Override with RATE_LIMIT_COMMERCE_MAX_REQUESTS (default: 40% of RATE_LIMIT_MAX_REQUESTS).
A tenant’s budget is isolated — Tenant A exhausting its rate limit has zero impact on Tenant B’s available requests.
Configuration:
| Setting | Default | Description |
|---|---|---|
DEFAULT_MAX_REQUESTS |
100/15s | Per-IP limit (existing) |
DEFAULT_TENANT_MAX_REQUESTS |
1000/15s | Per-tenant limit (10x IP limit) |
Response Headers:
| Header | Example | Description |
|---|---|---|
X-RateLimit-Remaining |
42 |
Per-IP remaining requests |
X-RateLimit-Tenant-Remaining |
950 |
Per-tenant remaining requests |
X-RateLimit-Lane |
commerce |
commerce for /api/commerce/*, default otherwise |
X-RateLimit-Scope |
tenant |
Present only when a tenant-level limit was exceeded (distinguishes from IP-level block) |
Retry-After |
15 |
Seconds until the window resets |
The adaptive cost multiplier from SystemMonitor (0.8x idle → 2.0x critical) applies to both tiers equally, ensuring system-wide pressure protection.
Best Practices
When to Use Global Resources
Use global resources when:
- ✅ Data should be shared across all tenants (e.g., product catalog, shared templates)
- ✅ Consistency is required across tenants (e.g., system-wide roles)
- ✅ Reducing data duplication is important
- ✅ Centralized management is preferred
When to Use Tenant-Specific Resources
Use tenant-specific resources when:
- ✅ Data must be completely isolated (e.g., customer orders, private documents)
- ✅ Compliance requires data segregation (e.g., GDPR, HIPAA)
- ✅ Tenants need independent customization (e.g., custom roles, workflows)
- ✅ Data ownership is tenant-specific
Hybrid Approach
Most applications benefit from a hybrid approach:
// Example: E-commerce Platform
{
// Global Resources
productCatalog: { tenantId: undefined }, // Shared products
systemRoles: { tenantId: undefined }, // Base roles
// Tenant-Specific Resources
orders: { tenantId: 'tenant-123' }, // Isolated orders
customers: { tenantId: 'tenant-123' }, // Isolated customer data
customRoles: { tenantId: 'tenant-123' } // Tenant-specific roles
}
Migration from Single-Tenant to Multi-Tenant
Automatic Migration on Enable
When switching from single-tenant to multi-tenant mode:
-
Primary Tenant Creation: A primary tenant is automatically created (typically using the domain name or a configured identifier)
-
Automatic
tenantIdAssignment: All existing resources receive the primarytenantId:
// Collections
db.collections.updateMany(
{ tenantId: { $exists: false } },
{ $set: { tenantId: "primary-tenant-id" } },
);
// Roles
db.roles.updateMany(
{ tenantId: { $exists: false } },
{ $set: { tenantId: "primary-tenant-id" } },
);
// Users
db.users.updateMany(
{ tenantId: { $exists: false } },
{ $set: { tenantId: "primary-tenant-id" } },
);
- 100% Separation: After migration, all resources are fully isolated to the primary tenant by default
Creating Global Resources (Post-Migration)
To share resources across tenants after migration, explicitly set tenantId to null:
// Make a role global (shared across all tenants)
await dbAdapter.auth.updateRole(
roleId,
{
tenantId: null,
},
"primary-tenant-id",
);
// Make a collection global
await dbAdapter.content.nodes.update(collectionPath, {
tenantId: null,
});
Converting Tenant-Specific to Global
// Remove tenantId to make resource global
await dbAdapter.crud.update("roles", roleId, {
tenantId: null, // Explicitly set to null for global access
});
Converting Global to Tenant-Specific
// Add tenantId to isolate resource
await dbAdapter.crud.update("roles", roleId, {
tenantId: "tenant-123",
});
⚠️ Warning: Converting resources between global and tenant-specific requires careful planning to avoid data access issues.
Migration Checklist
When enabling multi-tenancy:
- Backup Database: Always backup before enabling multi-tenancy
- Configure Primary Tenant: Set the primary tenant identifier in configuration
- Run Migration Script: Execute the tenant migration script to assign
tenantIdto all resources - Verify Isolation: Confirm all resources have
tenantIdassigned - Identify Global Resources: Determine which resources (if any) should be shared globally
- Update Global Resources: Explicitly set
tenantId: nullfor shared resources - Test Access: Verify tenant isolation and global resource access
- Update Documentation: Document which resources are global vs tenant-specific
Security Considerations
- Permission Checks: Always validate that users have appropriate permissions for both global and tenant-specific resources
- Cross-Tenant Access: Prevent users from accessing resources outside their tenant (except for explicitly global resources)
- Audit Logging: Track access to both global and tenant-specific resources for compliance
- Data Leakage: Ensure queries properly filter by
tenantIdto prevent accidental cross-tenant data exposure
Performance Optimization
-
Indexing: Create compound indexes on
tenantId+ frequently queried fieldsdb.collection.createIndex({ tenantId: 1, createdAt: -1 }); -
Caching: Use tenant-aware cache keys
const cacheKey = tenantId ? `tenant:${tenantId}:roles` : "global:roles"; -
Query Optimization: Leverage global resources to reduce data duplication and improve query performance
Admin Management
Admins can manage tenants via the Tenant Dashboard (/admin/tenants).
Features
- List View: View all tenants, their plans, and status.
- Quota Visualization: Real-time progress bars for Users, Storage, and Collections.
- Status Control: Suspend or Activate tenants instantly.
- Suspended: Users prevent from logging in; API access blocked.
- Create Tenant: Provision new tenants manually with specific plans.
This UI interacts directly with TenantService, ensuring all changes are auditable and safe.