Multi-tenancy — tenant isolation, per-tenant DBs, context & timezone

The whole tenancy model reduces to one idea:

Every tenant gets its own pair of OS processes and its own MongoDB database. There is no shared application database for business data and no row-level tenantId on business collections — isolation is at the database level, enforced by giving each tenant process its own mongodb_url. A small platform-level master DB holds the registry of tenants and their limits/feature whitelist.

Source: zerp-be/CLAUDE.md, zerp-be/src/modules/tenant-config, zerp-be/src/context.ts, zerp-be/src/core/database. Related: architecture, auth, company-branch, config, subscription/features.


1. Process & database model

                ┌─────────────────────────── master / control plane (zerp-master) ───────────────────────────┐
                │  master MongoDB  →  tenants collection (key, limits, feature whitelist, suspended flag, TZ)   │
                └───────────────────────────────────────────────────────────────────────────────────────────┘
                                 │ starts/stops/deletes PM2 processes, runs migrations (SSE)
        ┌────────────────────────┼────────────────────────┐
        ▼                        ▼                         ▼
  tenant "atlantic"        tenant "acme"            tenant "…"
  zerp-atlantic-be  ◀──┐   zerp-acme-be  ◀──┐
   (own mongodb_url)   │    (own mongodb_url) │      each: be + admin PM2 pair,
  zerp-atlantic-admin ─┘   zerp-acme-admin ──┘      own DB, own ports, own TZ
  • One PM2 process per tenant per app — e.g. zerp-atlantic-be(:4060) + zerp-atlantic-admin(:…). Started/stopped/deleted from the zerp-master control plane.
  • One MongoDB database per tenant (DB name = tenant key, no prefix by default). Business data never crosses tenants because the connection itself is tenant-scoped.
  • Master DB is connected to separately via mongodb_master_url; it holds the tenants collection (limits, feature whitelist, suspended status) consumed at boot.
  • The admin process targets its tenant's backend GraphQL endpoint at runtime via NEXT_PUBLIC_SERVER_URL.

Required backend environment variables

Var Purpose
mongodb_url This tenant's own database
mongodb_master_url Platform/master DB (for TenantConfigService)
tenant_key Tenant identifier
internal_master_secret Shared secret for master → tenant RPCs (e.g. feature-cache clear)
port HTTP port
TZ IANA timezone (optional; host default if absent)
app_env local / dev / production — controls Swagger + cache TTLs

2. Tenant config (loaded at boot)

TenantConfigService reads this tenant's row from the master DB tenants collection at boot and caches it. It supplies:

  • LimitsmaxCompanies, maxUsers, maxEmployee. Hard-enforced in company/user/employee create paths.
  • Feature whitelist — the set of features this tenant is allowed, intersected with the subscription plan catalog by the feature resolver (see subscription/features). development_mode defaults to dedicated, which ignores the subscription and grants the full catalog.
  • Suspended flag — a suspended tenant is blocked.

The master can push a cache-clear RPC to a tenant process (authenticated with internal_master_secret) when its config changes.


3. Two levels of partitioning

There are two distinct partition concepts — don't confuse them:

  1. Tenant (database-level) — described above. A whole isolated installation. No business collection carries a tenant id.
  2. Company / branch / store (row-level, within a tenant's DB) — a single tenant may run multiple companies/subsidiaries, each with branches and stores. Business documents carry companyId (and often branchId). This scoping is injected automatically by core/database/database.repository.ts from contextSvc.companyId. See company-branch. (Branch-level read filtering exists in code but is currently commented-out/dormant; company-level scoping is active.)

So: tenant → companies → branches → stores → documents.


4. Per-request context

ApContextMiddleware populates ApContextService (src/context.ts) for every request from the authenticated token (see auth):

  • userId, companyId, branchId, role/permission info, employeeId (for ESS).
  • Services and repositories read contextSvc.companyId / contextSvc.branchId on every query/mutation. Never hardcode these.

5. Timezone correctness (project-critical rule)

Each tenant process receives a TZ env var so new Date() / moment() / dayjs() resolve in the tenant's local timezone. The contract:

  • The frontend sends date-range boundaries as pre-aligned millisecond timestamps (already aligned to the user's TZ).
  • The backend passes them through as-is. With TZ set, moment() agrees with the frontend boundary.
  • Do NOT re-apply moment(ts).startOf('day') / endOf('day') on backend-received timestamps. Doing so shifts the boundary by the server→tenant offset and causes off-by-one-day bugs for non-UTC tenants. Do not read TZ manually — rely on Node's built-in behavior.

6. Migrations

One-off scripts in zerp-be/src/migrations/ (e.g. 2026-04-14-fix-cross-company-categoryids.ts) are invoked per-tenant from zerp-master's Migrate button (streamed over SSE). Rules: idempotent (safe to re-run), read mongodb_url from env (never hardcode), log progress, exit non-zero on failure.