Factorizer/Cluster ERP WikiCluster ERP Türkçe
Home / Identity and access

Explanation

Multi-tenancy

The database-per-tenant model in core/tenancy.py, how a request is routed to its tenant database, and how memberships suspend and close tenants.

Cluster ERP can serve many customer companies from one deployment. Each company, a tenant (Tenant), has its own physical PostgreSQL database; a separate control-plane database holds the tenant registry, the commercial memberships, operator accounts, and rate-limit counters. The mode is off by default and turned on with the setting multi_tenant. This page explains core/tenancy.py, the tenant routing in server/dependencies.py, the request context in core/request_context.py, and the membership commands in server/membership.py. Provisioning and offboarding commands are in tenant lifecycle.

In short

  • Isolation is by database: a request resolved to tenant A gets an engine whose connection names A's database, so there is no query filter to forget (src/cluster_erp/core/tenancy.py:1-16).
  • Control-plane tables use their own declarative base, ControlBase, so tenant schema creation never creates them and the reverse (src/cluster_erp/core/tenancy.py:86-93).
  • A request is routed by the signature-verified tenant claim of its token, or at sign-in by the X-Tenant-Domain header; anything that does not resolve to an active tenant gets one generic HTTP 401 (src/cluster_erp/server/dependencies.py:104-157).
  • A tenant has four statuses in the database constraint: provisioning, active, suspended, and closed (src/cluster_erp/core/tenancy.py:124-129).
  • A daily pass suspends tenants whose membership period has ended (src/cluster_erp/core/tenancy.py:324-354, src/cluster_erp/server/app.py:372-403).

How it works

Control plane

ControlBase declares six tables, counted with grep __tablename__ in core/tenancy.py:

Entity Table Purpose Source
Tenant control_tenants Key, name, status, database name, mail domains, region, closing data src/cluster_erp/core/tenancy.py:96-129
Operator control_operators Operator console accounts src/cluster_erp/core/tenancy.py:132-154
OperatorAction control_operator_actions Audit trail of operator actions src/cluster_erp/core/tenancy.py:157-170
RateLimitBucket control_rate_limits Shared fixed-window counters for anonymous routes src/cluster_erp/core/tenancy.py:173-183
ControlRetentionRun control_retention_runs Summary of each control-plane retention pass src/cluster_erp/core/tenancy.py:186-198
Membership control_memberships Plan, status, admin e-mail, payment reference, period end, provisioning state, signup tokens as digests src/cluster_erp/core/tenancy.py:201-285

A tenant key must match a strict identifier pattern because it becomes the database name suffix, tenant_db_prefix plus key (src/cluster_erp/core/tenancy.py:42-46, src/cluster_erp/core/tenancy.py:458-468). Mail domains are stored lower-cased and IDNA-encoded by normalize_domain (src/cluster_erp/core/tenancy.py:53-70). Each tenant has a fixed data-residency region; its database lives on that region's server (src/cluster_erp/core/tenancy.py:107-108, src/cluster_erp/core/tenancy.py:593-595).

Tenant resolution

The flowchart shows how get_db_session finds the database of a request.

flowchart TD
 start["get_db_session"] --> mode{"multi_tenant on?"}
 mode -->|no| default["process default Database"]
 mode -->|yes| token["routing_token from Bearer or cookies"]
 token --> claim["read_tenant_claim, then TenantRegistry.by_key"]
 claim --> found{"tenant found?"}
 found -->|no| domain["X-Tenant-Domain, then TenantRegistry.by_domain"]
 found -->|yes| active
 domain --> active{"status active?"}
 active -->|no| err["AUTH_TENANT_UNRESOLVED 401"]
 active -->|yes| ctx["request.state.tenant_key, set_tenant_context"]
 ctx --> engine["TenantRegistry.database_for"]

In prose:

  1. With multi_tenant off, every request uses the process-wide database (src/cluster_erp/server/dependencies.py:116-118).
  2. Otherwise the dependency takes the Bearer token, the access cookie, or the refresh cookie; for a Bearer refresh it reads the refresh token from the body (src/cluster_erp/server/dependencies.py:127-134, src/cluster_erp/modules/auth/session_cookies.py:87-109).
  3. read_tenant_claim returns the tenant key only from a token whose signature verifies; expiry is ignored so that an expired session can still refresh (src/cluster_erp/modules/auth/jwt_utils.py:63-77).
  4. Without a usable claim, the X-Tenant-Domain header is matched against live, active tenants (src/cluster_erp/server/dependencies.py:139-142, src/cluster_erp/core/tenancy.py:477-493).
  5. A missing or non-active tenant raises AUTH_TENANT_UNRESOLVED with HTTP 401 (src/cluster_erp/server/dependencies.py:143-148).
  6. The tenant key is published on request.state for the login route and in the request context for storage keys (src/cluster_erp/server/dependencies.py:149-150, src/cluster_erp/core/request_context.py:23-27).
  7. database_for returns a cached engine for the tenant (src/cluster_erp/server/dependencies.py:151-157).

Engine cache

TenantRegistry keeps at most tenant_max_engines tenant engines per process and evicts the least recently used one, closing its pool. Each tenant engine uses tenant_pool_size and tenant_pool_max_overflow (src/cluster_erp/core/tenancy.py:288-305, src/cluster_erp/core/tenancy.py:597-615). At start-up the registry warns when the connection budget can exceed the server's limit (src/cluster_erp/server/app.py:119-121).

Background work per tenant

Scheduled jobs iterate over active_tenants, optionally including suspended tenants for retention and log purges (src/cluster_erp/core/tenancy.py:495-510). The session dependency also publishes the request's tenant database in a context variable, so EventBus handlers open their sessions on the caller's tenant (src/cluster_erp/server/dependencies.py:178-184).

Membership and status

Status Meaning Set by Source
provisioning Registered; the database is being created create_tenant src/cluster_erp/core/tenancy.py:443-475
active Resolves at sign-in provisioning, extend, activate src/cluster_erp/server/membership.py:83-125
suspended Does not resolve; data kept expiry sweep, suspend src/cluster_erp/core/tenancy.py:324-354, src/cluster_erp/server/membership.py:108-125
closed Relationship ended; row kept with note; memberships cancelled close_tenant src/cluster_erp/core/tenancy.py:550-585

The membership command line offers list, sweep, retention, extend, suspend, and activate (src/cluster_erp/server/membership.py:156-170). The operator console offers extend, suspend, and activate as well; see operator console.

Why it is built this way

  • The module docstring states that one physical database per customer makes isolation hold by construction (src/cluster_erp/core/tenancy.py:10-14).
  • Unknown domains and non-active tenants answer like a wrong password so that the endpoint cannot enumerate customers (src/cluster_erp/core/tenancy.py:477-483, src/cluster_erp/server/dependencies.py:112-114).
  • The billing state lives on the control plane so that a suspended customer stays suspended whatever its own database says (src/cluster_erp/core/tenancy.py:201-206).
  • The engine cache is bounded so that a long-lived worker serving many tenants cannot exhaust database connections (src/cluster_erp/core/tenancy.py:288-296).

Rules and boundaries

  • multi_tenant defaults to false; a single-tenant deployment keeps its previous behaviour (src/cluster_erp/config/settings.py:78-84).
  • In multi-tenant mode the settings validator requires storage, backup, and mail encryption keys (src/cluster_erp/config/settings.py:805-821). See second factor and secret storage.
  • Signup and the operator console routes are mounted only in multi-tenant mode (src/cluster_erp/server/app.py:1440-1457).
  • tests/test_tenancy.py (12 tests) and tests/test_refresh_routing_body.py (3 tests) pin the pure tenancy rules and body routing; the e2e suites web-client/_e2e_tenant_isolation.mjs, web-client/_e2e_sep_companies.mjs, and web-client/_e2e_tenant_regions.mjs cover isolation and regions (counted with grep -c "def test_" and found by name).

Interaction with other parts

Limits and open points

  • Moving a tenant to another region is not determined from code; the field comment states that a region never changes (src/cluster_erp/config/settings.py:90-92).
  • deleted_at on Tenant is set by close_tenant (src/cluster_erp/core/tenancy.py:568-569); a reopen path is not determined from code.

Terms introduced

Term Code identifier Definition Turkish term Source
tenant Tenant One customer company with its own physical database. kiracı src/cluster_erp/core/tenancy.py:96
control plane ControlBase The separate database of tenants, memberships, operators, and counters. kontrol düzlemi src/cluster_erp/core/tenancy.py:86
membership Membership The commercial record behind a tenant: plan, status, period, and provisioning state. Üyelik (webapp/i18n/tr.json key signup.title) src/cluster_erp/core/tenancy.py:201
tenant key Tenant.key The stable lowercase identifier of a tenant, also used as the suffix of its database name. kiracı anahtarı src/cluster_erp/core/tenancy.py:99
tenant registry TenantRegistry The per-process accessor of the control plane with a bounded engine cache. kiracı kayıt defteri src/cluster_erp/core/tenancy.py:288

Verified against commit 92f8051 on 2026-11-13.

Verified against commit 92f8051 · 2026-11-13