Skip to content
Z-CMS is being built in the open on GitHub. Join the community β†’

← All posts

How Multi-Tenancy Is Implemented in Z-CMS

Jul 14, 2026 Β· Z-SOFT Admin Β· 4 min read

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:123
tenants/tenant-a/media/2026/07/image.jpg

Storage 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 β†’ Viewer

Authentication 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