← Architecture

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

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:

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:

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:

  1. retrieves the tenant database password through SecretStoreInterface;
  2. builds the physical tenant datasource from trusted platform metadata;
  3. saves the previous default/tenant datasource state and table locator;
  4. aliases tenant to default and installs a fresh TableLocator;
  5. applies tenant mail configuration and enters TenantContext;
  6. runs the callback; and
  7. 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:

  1. validate a unique slug, host set, database name/role, and requested settings;
  2. create central tenant/host metadata in provisioning state;
  3. create or configure the PostgreSQL role and database;
  4. store the tenant database password and tenant backup KEK through the secret store;
  5. apply core and all loaded plugin migrations;
  6. initialize required tenant settings and run smoke checks;
  7. optionally create the initial tenant superuser; and
  8. 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:

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:

  1. dispatch due platform schedules;
  2. iterate enabled, active tenants and drain bounded tenant queue work inside withTenant(); and
  3. 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

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:

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: