Skip to main content
Version: v0.25.0 (Latest)

Tenant Isolation

Multi-tenant deployments stand or fall on isolation. The enterprise deployment uses two database roles: a platform database owned by the platform service, and a tenant workload database used by the tenant runtime services. Tenant workload data is isolated with one schema per tenant by default. The EDK also supports row-level isolation and database-per-tenant routing for custom assemblies or deployments that need a different operational model. Database isolation is only one layer; per-tenant signing keys and authorization scope on every admin and protocol call close different classes of failure.

Enterprise Default: Schema-Per-Tenant

In the published enterprise deployment, each tenant has its own schema in the tenant workload database. The platform provisions the schema when the tenant is registered, and the database router sets the request's search path to the resolved tenant schema. Tenant runtime services then read and write their normal tables without co-locating tenant rows in one shared schema.

The platform service has its own database for setup state, license activation state, tenant registry, routing records, platform configuration, and the platform-only authorization server. Runtime services do not connect to that platform database.

Row-Level Isolation

For custom deployments that choose one shared schema, every tenant-scoped record carries a tenant id; every query filters on it; every tenant-scoped uniqueness constraint includes the tenant id. It is enforced in three places: the schema requires a tenant id on every tenant-scoped row, the data layer always filters by the resolved tenant, and a tenant-scoped command runs inside a tenant context rather than accepting a tenant id as an argument. By construction a tenant cannot see another tenant's rows: a cross-tenant access either matches no constraint, returns an empty result, or is refused.

Database-Per-Tenant Routing

For deployments that need stricter separation than schema-per-tenant, the EDK can route a request bound to a specific tenant to a specific database or PostgreSQL instance. The routing is configuration-driven:

database:
routing:
default:
url: jdbc:postgresql://postgres-shared:5432/edk
username: edk_app
password: ${secret:vault:edk/postgres/shared/password}
tenants:
acme:
url: jdbc:postgresql://postgres-acme:5432/edk
username: edk_app_acme
password: ${secret:vault:edk/postgres/acme/password}
regulated-customer:
url: jdbc:postgresql://postgres-regulated:5432/edk
username: edk_app_regulated
password: ${secret:vault:edk/postgres/regulated/password}

Each target has its own connection pool. The application code is unchanged; the same operations route to whichever target the resolved tenant points at. Common patterns:

  • Tenant workload database with schema-per-tenant. The enterprise default. One workload database contains separate schemas such as tenant_acme and tenant_globex.
  • Single shared schema, row-level isolation. A custom assembly option. Cheaper and simple to operate, but tenants share one schema.
  • Database per tenant, shared instance. One Postgres instance hosts a database per tenant. Database-level isolation with more per-tenant provisioning.
  • Postgres instance per tenant. Maximum isolation, for regulated or contractual cases where a tenant's data must not share a server with another's.

The choice is per deployment and can mix and match, so a deployment can keep most tenants in the tenant workload database and route regulated tenants to a dedicated database or instance. Adding a routing entry takes effect on the next resolution without a restart.

Provisioning Non-Shared Isolation

For schema-per-tenant isolation, tenant registration provisions the tenant schema, runs the schema migrations, creates the application grants, and seeds any deployment-wide defaults for that tenant. For database-per-tenant or instance-per-tenant isolation, the tenant's storage target has to be created first (create the database, run the schema migrations, create the application role and grants, seed any deployment-wide defaults).

This is an extension point. To run a non-default storage target, provide a provisioner that creates the per-tenant storage at registration time, and a strategy that decides which isolation a given registration gets (so acme can stay schema-per-tenant while regulated-customer is database-per-tenant).

Per-Tenant Signing Keys

Every signing duty maps to a per-tenant key alias of the form (tenant, service, purpose):

  • Issuer credential signing: (acme, issuer, credential-signing).
  • AS access token signing: (acme, as, access-token-signing).
  • AS id token signing: (acme, as, id-token-signing).
  • Verifier request-object signing: (acme, verifier, request-object-signing).
  • Audit checkpoint signing: (acme, audit, audit-checkpoint).
  • Webhook HMAC: (acme, <service>, webhook-signing).
  • DID update keys: (acme, did, did-update).

The KMS resolves each alias to a specific key in the configured provider. Two tenants with the same alias structure resolve to different keys; rotation is per alias and per tenant, so rotating acme's credential signing key does not touch beta's. The service never holds raw key material: the alias is the contract, and the key lives in the configured provider (software keystore, HSM, AWS KMS, Azure Key Vault, Digidentity CSC). A compromised service container gains the signing capability for the duration of the compromise, not the key. See the KMS container page for the full alias contract.

Encryption At Rest

Several subsystems persist intermediate state carrying sensitive material (the assembled attribute bag in an OID4VCI session, the principal context in an OAuth session, an integration secret reference). These payloads are encrypted at rest with a per-tenant key. Three modes:

  • Plaintext. Development only. Sessions stored as plain JSON.
  • Platform-encrypted. The production default. Sessions are sealed with AEAD under a tenant-specific key derived from the deployment master with the tenant id as salt, so a compromise of one tenant's key does not yield another's plaintext.
  • Client-bound. Additionally binds the encryption to the session's correlation id, so a session is unreadable except when the matching correlation id is presented (typically by the same wallet that started it).

The mode is configurable per tenant through tenant config, per subsystem (oid4vci.session.encryption_mode for the issuance session, oauth2.session.encryption_mode for the OAuth session). The default is platform-encrypted.

Authorization Scope

Beyond data isolation, the admin API enforces that a tenant administrator can act only on their own tenant. The JWT is bound to a tenant, the request runs in that tenant's scope, data reads filter on it, and an admin operation compares the resolved tenant against the target named in the URL; a mismatch is refused with a 403.

A platform admin acting on a specific tenant uses an impersonation token, not a "magic URL". The platform admin's JWT is bound to the application tenant; to act on acme they obtain a short-lived JWT bound to acme from the application admin API and use that for the subsequent calls. The same scope check applies; a platform admin is simply allowed to obtain the impersonation token where a normal admin is not. The one path-encoded tenant id that names the operation target rather than the acting tenant is /tenants/{tenantId}/... for managing the tenant entity, and even there the path is checked against the acting tenant: only platform admins (on the application tenant) can mutate another tenant's record.

Suspension and Deletion

SUSPENDED is the explicit "exists but cannot serve traffic" state. The response to a suspended tenant depends on the surface: public protocol surfaces return 503 with a tenant_suspended error, admin REST returns 403, and the platform admin can still see and modify the suspended tenant from the application tenant. Suspension is reversible: returning the tenant to ACTIVE brings it back online, and the change propagates to every replica.

Soft delete is the harder state: the tenant disappears from listings and from resolution while its data remains in storage. Hard delete is not exposed through the API; it requires direct database work.

When Things Go Wrong

  • Cross-tenant read. Returns an empty result, surfaced as a 404; nothing leaks about whether the resource exists under another tenant.
  • Cross-tenant write. Blocked at the database constraint, surfaced as a 409, and recorded in the audit stream.
  • Resolver returns the wrong tenant. The admin authorization check catches the mismatch and refuses with a 403; a protocol call at worst returns the wrong tenant's metadata, which the wallet rejects on signature validation because the signing key resolves differently.
  • Per-tenant database unreachable. That tenant's requests fail with a 503; other tenants are unaffected.
  • Per-tenant KMS provider unreachable. That tenant's signing paths fail immediately; tenants on other providers are unaffected.