3.1 Multi-tenant architecture
This page is the canonical development contract for hosted KMP tenancy. The managed platform uses one shared application revision, a central platform database, and a separate PostgreSQL database for every kingdom.
Non-negotiable isolation contract
- Tenant business data is isolated by database, not by a
tenant_idcolumn. - The HTTP host is the only normal request-routing input for tenant selection.
- The platform registry is authoritative; there is no “default kingdom” fallback when a host is unknown or platform metadata is unavailable.
- Tenant context is established before tenant authentication, authorization, ORM access, domain services, and rendering.
- Background work names a tenant in platform metadata and enters that tenant’s connection scope before reading its job or application tables.
- Tables, connections, cache values, mail profiles, document paths, and transactions must not leak across a scope boundary.
- Cross-tenant reporting is not implemented by joining tenant databases or copying business rows into the platform database.
A public ID, member ID, branch ID, or platform operator identity does not select or authorize a tenant.
Data ownership
Platform database
The platform datasource owns central operational state, including:
- tenant records and normalized host mappings;
- platform-admin identities, sessions, login controls, and TOTP state;
- encrypted secret metadata and values;
- platform audit events;
platform_jobsandplatform_schedules;- tenant and platform backup metadata/settings; and
- privacy-safe tenant operational aggregates.
It does not own member, branch, role, warrant, gathering, workflow-instance, or
domain-plugin records. The current platform schema also does not provide a
generic feature-flag store, release catalog, or shared queue_messages table.
Do not design against retired or proposed platform tables without a migration
and implementation in the same change.
Tenant databases
Every tenant database has the complete core application schema and the schema of every loaded first-party plugin. It owns that kingdom’s:
- identities and sessions for the tenant-facing application;
- organizational and authorization records;
- domain workflows and approval runs;
- plugin data;
- application settings and tenant queue records; and
- tenant-facing backup/restore state where used by legacy self-service flows.
The CakePHP ORM continues to request the default datasource. During a tenant
scope, default is an alias to the physical tenant connection.
Request resolution
TenantResolutionMiddleware runs after CakePHP routing and before
restore-maintenance, CSRF, tenant authentication, and authorization.
| Condition | Result |
|---|---|
| Tenancy disabled | Continue with the configured default datasource |
Exact /health path |
Platform health handling without tenant binding |
| Configured platform-admin host | Allow only the platform-admin/assets surface without tenant binding |
| Platform metadata unavailable | 503 and no fallback |
| Host absent from registry | 404 |
Tenant not active |
503 |
| Recorded tenant schema behind the required application schema | 503 maintenance response |
| Active, current tenant | Execute the remainder of the request in its tenant scope |
Hosts are normalized to lowercase with a trailing dot removed before comparison. Host aliases must be explicit registry records. Do not trust forwarded host headers unless the deployment’s proxy trust boundary has been configured and reviewed.
The request records only bounded, route-level operational aggregates. Route metrics omit path IDs, query strings, request bodies, and member data.
Connection and context lifecycle
TenantConnectionManager::withTenant() is the only supported low-level entry
point for scoped application work. It:
- retrieves the tenant database password through
SecretStoreInterface; - builds the physical
tenantdatasource from trusted platform metadata; - saves the previous
default/tenantdatasource state and table locator; - aliases
tenanttodefaultand installs a freshTableLocator; - applies tenant mail configuration and enters
TenantContext; - runs the callback; and
- in
finally, restores mail, datasource aliases/configuration, context, and the original table locator.
If scoped code returns with an open database transaction, the manager rolls it back and throws. This converts a potential cross-request state leak into a loud failure.
Never retain an entity table, query object, connection, service containing a
tenant table, or lazy callback beyond withTenant(). Resolve it inside each
scope. New process-global state must have an equivalent snapshot/restore
contract and cross-tenant tests.
Authentication and authorization
Tenant hosts use the normal CakePHP member authentication and authorization stack after binding. Tenant web session cookies are host-only by default. Every controller still authorizes the resource/model and scopes collection queries; database isolation does not replace authorization within a kingdom.
The platform administration surface uses central platform-admin identities, sessions, lockout controls, TOTP, audit, and an allowlisted host. It bypasses tenant member authentication because no tenant is selected. A platform operator is not automatically a tenant member or tenant superuser.
KMP contains a TenantCsrfTokenScope extension point, but it is not currently
wired as a tenant-bound token scheme. Security claims should instead describe
the implemented ordering and host-only session boundary.
Lifecycle states
Tenant status is a platform-owned operational gate:
provisioning ──▶ active ──▶ suspended ──▶ active
│ │
└─────────────────────────┴──────▶ archived
Only the implemented transitions are accepted: active → suspended,
suspended → active, and provisioning|suspended → archived. Lifecycle changes
require a reason, create an audit record, take a PostgreSQL advisory lock, and
reject conflicting operations. Reactivation also requires the tenant to match
the exact current migration catalog.
An archived tenant is not a deletion signal. Database, secrets, backups, and retention handling remain explicit operational work.
Provisioning
TenantProvisioningService and bin/cake tenant provision coordinate managed
provisioning. A successful activation includes:
- validate a unique slug, host set, database name/role, and requested settings;
- create central tenant/host metadata in
provisioningstate; - create or configure the PostgreSQL role and database;
- store the tenant database password and tenant backup KEK through the secret store;
- apply core and all loaded plugin migrations;
- initialize required tenant settings and run smoke checks;
- optionally create the initial tenant superuser; and
- record the verified schema and activate the tenant.
A provisioning run that skips migrations cannot activate the tenant. Failures must leave enough audited metadata for safe diagnosis or archival; do not hide a partially provisioned database by marking it active.
Fleet migrations
There are separate platform and tenant migration tracks:
- platform migrations live under
app/config/PlatformMigrationsand run withbin/cake platform_migrate; - tenant core migrations live under
app/config/Migrations; - tenant plugin migrations live under each loaded plugin’s
config/Migrationsdirectory.
TenantMigrationCatalog computes one target from core plus every loaded plugin
and recognizes the configured legacy per-scope Phinx history tables. It reports
pending migrations and unexpected history drift rather than trusting only a
single version string.
bin/cake tenant migrate accepts exactly one tenant selection or --all.
Active tenants are selected by default; fleet releases add
--include-suspended. Each tenant receives a PostgreSQL advisory lock. When a
migration is actually needed, the runner creates the required encrypted
pre-migration marker, runs pending scopes in catalog order, verifies every
scope, and only then records the schema version. A current tenant is verified
without an unnecessary backup.
The managed release sequence is platform migration and secret/key readiness, then:
bin/cake tenant migrate --all --include-suspended --fail-fast
Do not reactivate a suspended tenant that is behind. Do not modify an already released migration; add a forward migration. See Migration lifecycle.
Background jobs and schedules
Tenant application tasks use queued_jobs in the selected tenant database.
Fleet operations use central platform_jobs, while platform_schedules stores
allowlisted recurring definitions.
PlatformWorkerService runs bounded cycles in this order:
- dispatch due platform schedules;
- iterate enabled, active tenants and drain bounded tenant queue work inside
withTenant(); and - run bounded platform jobs.
The older model of independent recurring platform-job and tenant-queue drain schedules has been retired. Add recurring operations through the allowlisted platform schedule dispatcher and preserve idempotency, locking, retry, timeout, and audit semantics.
A suspended or archived tenant is not normal application queue work. Migration and specifically authorized maintenance commands may include suspended tenants without making them active.
Cache, mail, and documents
- Use
TenantAwareCachefor application data. Tenant cache keys include the active tenant scope; platform cache entries use a platform namespace. - The host-map cache is platform-scoped and is explicitly invalidated when the registry changes.
- Tenant mail configuration is applied and restored with the connection scope. Pre-format dates in the tenant/user display timezone before passing data to a mailer.
- Resolve document storage through
TenantDocumentStorageConfigResolverand the owning storage service. A shared storage account may use tenant-specific containers/prefixes; that is logical application isolation, not independent cloud-account RBAC.
Any new integration that caches credentials or clients must key and dispose of them at the same boundary.
Backup and restore
Managed tenant backups are logical, gzip-compressed JSON archives encrypted
before storage (.json.gz.enc). Each backup uses a data-encryption key wrapped
by the tenant KEK; platform metadata records the object reference, checksum,
key information, state, and verification data without storing plaintext keys.
Legacy PostgreSQL dump restoration remains a compatibility path.
A managed destructive restore requires the target tenant to be suspended and an explicit confirmation. Restore services validate/decrypt, apply supported payload upgrades, restore, and verify before lifecycle decisions are made. Tenant restore drills use isolated plans and verification rather than overwriting an active tenant.
The platform database has a separate encrypted pg_dump backup format
(.pgdump.enc) and recovery procedure. It must not be sent through the tenant
JSON restore path. The tenant-facing RestoreMaintenanceMiddleware primarily
supports the legacy/self-service flow and is not a substitute for the managed
suspended-tenant restore contract.
See Backup and restore.
Development checklist
For every change that touches application state, ask:
- Which database owns this record?
- Can this code execute before tenant binding or on the platform-admin host?
- Does a cache, singleton, table locator, client, or mail setting survive the scope?
- Does a queued payload carry a stable tenant identifier without trusting user input?
- Are inactive and schema-behind tenants rejected correctly?
- Does the migration catalog include all loaded plugin scopes?
- Are secrets and telemetry scrubbed?
- Is there a test that runs tenant A, tenant B, then tenant A again in the same process?
At minimum, tenant-sensitive tests should prove host resolution fails closed, IDs from another tenant cannot be resolved in the current database, per-tenant configuration is restored, and sequential jobs do not reuse prior tenant state.
Source map
The main implementation points are:
app/src/Middleware/TenantResolutionMiddleware.phpapp/src/Services/TenantConnectionManager.phpapp/src/KMP/TenantContext.phpapp/src/Services/Platform/TenantHostResolver.phpapp/src/Services/Platform/TenantLifecycleService.phpapp/src/Services/Platform/TenantProvisioningService.phpapp/src/Services/Platform/TenantMigrationCatalog.phpapp/src/Services/Platform/PlatformWorkerService.phpapp/src/Services/Cache/TenantAwareCache.phpapp/src/Services/Storage/TenantDocumentStorageConfigResolver.php