How Multi-Tenancy Is Implemented in Z-CMS
One CMS Core, many websites, with isolation enforced across every infrastructure layer
Multi-tenancy is a cross-cutting architecture
Adding a tenant_id column is not enough. Tenant boundaries affect authentication, authorization, database access, cache, object storage, queues, search, plugins, themes, logging and billing. A missing scope in any one layer can expose data across customers.
Tenant context must be resolved early, treated as immutable and enforced at the data boundary.
What a tenant represents
A tenant may represent a company, website, brand, agency customer, workspace or internal portal. Each tenant owns users, roles, content, media, domains, settings, plugins, themes and subscription data.
Tenant resolution
- Custom domain: company-a.com β tenant-a.
- Subdomain: tenant-a.z-cms.cloud β tenant-a.
- Path: /t/tenant-a.
- API header: X-Tenant-Code: tenant-a.
interface TenantContext {
id: string;
code: string;
domain?: string;
}The tenant is resolved at a trusted boundary. A tenantId supplied in a request body is never accepted as authoritative.
Request-scoped context
request.tenant = {
id: "tenant_a",
code: "tenant-a"
};Services consume the resolved context rather than parsing domains or headers repeatedly.
Database isolation models
Shared database and shared schema
All tenants share tables and rows are separated by tenant_id. This is operationally efficient for many small tenants but requires strict filtering.
CREATE TABLE posts (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
title TEXT NOT NULL,
content TEXT
);Separate schema or database
Enterprise tenants may receive a dedicated schema or database for stronger isolation, independent backup and compliance. Z-CMS can expose the same repository contract over shared and dedicated storage models.
Tenant-aware repositories
Developers should not manually remember tenant filters in every query. A repository is bound to the current tenant and adds the scope automatically.
class TenantPostRepository {
constructor(private db: Database, private tenant: TenantContext) {}
findMany() {
return this.db.posts.findMany({
where: { tenantId: this.tenant.id }
});
}
}PostgreSQL Row-Level Security
Application scoping can be reinforced with database policies.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy
ON posts
USING (tenant_id = current_setting('app.tenant_id')::uuid);Defense in depth ensures that even an accidentally unfiltered query cannot read rows from another tenant.
Cache, storage and search isolation
Wrong: post:123
Right: tenant:tenant-a:post:123tenants/tenant-a/media/2026/07/image.jpgStorage APIs accept a logical path and add the tenant prefix internally. Search queries always include a tenant filter or use a dedicated enterprise index.
Queue and worker context
{
"job": "content.publish",
"tenantId": "tenant-a",
"payload": { "postId": "post-123" }
}Retries preserve the original tenant context. Workers validate the context before loading any resource.
Membership and authorization
[email protected]
βββ tenant-a β Owner
βββ tenant-b β Editor
βββ tenant-c β ViewerAuthentication identifies the user. Membership determines whether the user can enter the tenant. Role and permission checks then determine which actions are allowed.
Plugin and theme isolation
Each tenant activates its own plugin versions, settings and theme. Plugin SDK calls are automatically scoped to tenantId plus pluginId, and rendered-page cache keys include tenant and theme version.
Observability and rate limiting
{
"level": "info",
"tenantId": "tenant-a",
"requestId": "req-123",
"pluginId": "plugin-seo",
"message": "SEO metadata updated"
}Rate limits can be applied per IP, user, tenant, API key and plugin so one customer cannot exhaust shared resources.
Testing for cross-tenant leakage
const tenantA = await createTenant();
const tenantB = await createTenant();
await createPost(tenantA, { title: "Tenant A Post" });
const posts = await listPosts(tenantB);
expect(posts).toHaveLength(0);Security tests should intentionally submit resource identifiers belonging to another tenant and verify that every layer rejects them.
Conclusion
Z-CMS treats multi-tenancy as an end-to-end invariant. Request resolution, membership, repositories, row-level security, cache keys, storage paths, jobs, search, plugins, themes and logs all carry the same immutable tenant context. The goal is to make the safe path the default path for every developer.
GitHub: https://github.com/zscontributor/z-cms
Website: https://z-cms.org