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-Domainheader; anything that does not resolve to anactivetenant 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, andclosed(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:
- With
multi_tenantoff, every request uses the process-wide database (src/cluster_erp/server/dependencies.py:116-118). - 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). read_tenant_claimreturns 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).- Without a usable claim, the
X-Tenant-Domainheader is matched against live, active tenants (src/cluster_erp/server/dependencies.py:139-142,src/cluster_erp/core/tenancy.py:477-493). - A missing or non-active tenant raises
AUTH_TENANT_UNRESOLVEDwith HTTP 401 (src/cluster_erp/server/dependencies.py:143-148). - The tenant key is published on
request.statefor 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). database_forreturns 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_tenantdefaults 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) andtests/test_refresh_routing_body.py(3 tests) pin the pure tenancy rules and body routing; the e2e suitesweb-client/_e2e_tenant_isolation.mjs,web-client/_e2e_sep_companies.mjs, andweb-client/_e2e_tenant_regions.mjscover isolation and regions (counted withgrep -c "def test_"and found by name).
Interaction with other parts¶
- Authentication and sessions stamps the tenant key into tokens.
- Signup creates tenants and memberships.
- Data residency check verifies regions.
- Database and sessions covers the session dependency in general.
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_atonTenantis set byclose_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 |
Related pages¶
- Identity, access, tenancy, privacy, and licensing
- Signup
- Operator console
- Tenant lifecycle
- Settings reference
Verified against commit 92f8051 on 2026-11-13.