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

Per-Tenant Configuration

The EDK has a single, layered configuration model that every data-plane container uses. App-level config comes from YAML, environment variables, and the cloud config providers. Per-tenant config lives in Postgres and is read through TenantConfigPropertySource. Per-principal config is the narrowest scope and is read from the principal context. The runtime resolver combines the three, and the rule is "narrower wins": a property defined at both the app and tenant level resolves to the tenant value within a request bound to that tenant, and to the app value otherwise.

This page covers the tenant scope specifically. The app scope is covered in the Container Deployment configuration page; the principal scope is a niche layer reserved for per-user overrides and rarely used outside specialised flows.

tenant_config_property

The shared Postgres table is:

tenant_config_property(
tenant_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT, -- plaintext, only for non-secret properties
secret_ref TEXT, -- reference into the configured secret backend
updated_at TIMESTAMPTZ NOT NULL,
updated_by_id UUID,
PRIMARY KEY (tenant_id, key)
)

Each row is one property in the tenant scope. The properties are dotted strings that match the EDK property naming convention (oauth2.servers.default.issuer, oid4vci.issuer.signing_key_alias, and so on). The runtime reads them through TenantConfigPropertySource, which is itself a contribution to the IDK PropertyResolver chain.

Two flavours of property:

  • Non-secret. The value lives in the value column. Examples: a tenant-specific cache TTL override, a feature toggle, a per-tenant URL base.
  • Secret-bearing. The secret_ref column carries a reference into the configured secret backend (secret:vault:edk/tenant-acme/idp-client-secret, secret:aws:edk/acme/webhook-hmac, and so on). The value column is null. The runtime resolves the reference at read time through the secret provider.

The classification is handled by TenantConfigSecretClassifier. Each typed admin endpoint declares which of the properties it writes are secret-bearing; the classifier routes secret writes through the secret backend rather than storing plaintext in Postgres.

Writing Tenant Config

Tenant administrators do not write to tenant_config_property directly. The table is the storage; the surface is typed admin REST per configuration domain. The pattern is consistent across the data planes:

  • The OID4VCI issuer config admin (/api/v1/credential/designs, /api/v1/issuer/oid4vci, /api/v1/issuer/oid4vci/integrations/suppliers).
  • The OID4VP verifier config admin (/api/v1/verifier/oid4vp, /api/v1/verifier/oid4vp/integrations/trust).
  • The AS instance admin (/api/v1/oauth2/servers, /api/v1/oauth2/clients, /api/v1/oauth2/federation/providers).
  • The DID method admin (/api/v1/did/methods).
  • The integration registry (/api/v1/integrations/*).
  • The webhook admin (/api/v1/webhooks).
  • The tenant admin itself for tenant-scoped infrastructure overrides.

Behind each typed surface, the endpoint command writes either the row directly (for non-secret values) or routes the secret half through the secret provider, leaving only the reference in tenant_config_property.

The EDK does not expose a raw JSONB tenant config endpoint, and intentionally so. Raw JSONB removes the type safety, makes secret classification harder to enforce, and creates a surface where a tenant administrator can write arbitrary configuration values the runtime is unprepared for. Every config domain has a typed REST surface that knows its own schema, its secret-bearing properties, and its validation rules.

Reading Tenant Config

The runtime reads tenant config through the standard PropertyResolver chain. The chain is:

  1. Principal scope properties (rarely used; for per-user overrides).
  2. Tenant scope properties (TenantConfigPropertySource).
  3. App scope properties (YAML, env, cloud providers).

The narrowest scope that has the property wins. Typed binders (@ConfigurationProperties-equivalent in Spring terms, Binder-equivalent in the IDK) read structured config blocks at the appropriate scope.

For a request bound to tenant acme, the resolver:

  1. Checks the principal scope (typically empty).
  2. Checks tenant_config_property for (tenant_id = 'acme', key = '<property>').
  3. Falls through to the app scope.

The cache fronting TenantConfigPropertySource keeps the per-tenant lookup off the hot path. Reads against the cached scope are O(1).

Secret References

A secret reference is a typed URI that names a backend and a key path:

secret:vault:edk/tenant-acme/idp-client-secret
secret:aws:edk/acme/webhook-hmac
secret:azure:edk-vault/acme/oauth-client-secret
secret:k8s:acme-secrets/idp-secret

The ${secret:...} interpolation in YAML and env vars resolves through the same SecretProvider SPI; the same syntax appears in the secret_ref column. The runtime resolution path is identical: the SecretProvider for the named backend fetches the value, caches it for a short TTL, and returns it to the consumer.

The reference itself is non-secret. Storing references in tenant_config_property is safe even if the database is exfiltrated; the actual secret lives only in the backend.

TenantConfigSecretClassifier is the bridge that makes this work. When a typed admin command stores a property the classifier recognises as secret-bearing, the command:

  1. Writes the secret value to the configured backend under a path derived from (tenant_id, property).
  2. Stores the resulting reference into tenant_config_property.secret_ref.
  3. Records an audit event with the reference and the writer principal, never the plaintext.

The classifier is itself a contribution to the property-source contribution chain. Each module declares which of its properties are secret-bearing by string match; the contribution is collected at app startup and consulted on every write.

Cross-Replica Invalidation

A tenant admin updating their config on replica A must not be silently overridden by replica B serving stale cached config. The invalidation mechanism is the same as the tenant resolver cache:

  1. The typed admin command writes to tenant_config_property (and optionally to the secret backend) and commits the transaction.
  2. After the commit, the command emits an application.tenant.config-updated event on the shared event bus.
  3. The Postgres LISTEN/NOTIFY bridge fans the event out to every replica subscribed to the channel.
  4. Each replica's TenantConfigPropertySource cache invalidates the affected (tenant_id, key) entry (or the whole tenant scope if the change is broader).
  5. The next read for that property hits Postgres and repopulates the cache.

The TTL fallback covers missed notifications. A property change that misses the notification is visible at most one TTL window later.

App / Tenant / Principal Scopes In Practice

A few examples that show how the scope chain composes:

Per-tenant signing key alias override. The default alias is (tenant, issuer, credential-signing). A tenant wants to override the alias to a deployment-specific KMS key. The override goes into tenant_config_property under oid4vci.issuer.signing_key_alias = "acme-custom-alias". The runtime read picks up the tenant value for the acme tenant; other tenants see their own override or the system-generated default.

Per-tenant webhook timeout. The default timeout is 5s. Tenant acme consumes a slow webhook endpoint and needs 30s. The override goes into webhook.dispatch.timeout_ms = "30000". The runtime reads the tenant value for acme's dispatch calls; other tenants stay at 5s.

Per-tenant SMTP override. A tenant wants their owner invitation emails to ship through their own SMTP server rather than the platform Email Service. The relevant overrides go into tenant_config_property (the SMTP host, the credentials reference). The Email Service consults the tenant scope when picking a transport.

Per-tenant external IdP client secret. Stored as a secret reference: secret_ref = "secret:vault:edk/tenant-acme/idp-client-secret". The runtime resolves to the actual secret at the point of use (the federation handshake).

Per-tenant audit signing enable. audit.events.signing.enabled = "true" enables audit checkpoint signing for one tenant without enabling it deployment-wide.

The TenantInvalidationBroker

TenantInvalidationBroker is the SPI that emits the invalidation events. The default impl ships in lib-tenant-resolution-impl and uses Postgres LISTEN/NOTIFY. A deployment standardised on a different event bus (Kafka, NATS, a service mesh's eventing) can bind a replacement that emits to that bus instead. The broker contract is intentionally narrow:

interface TenantInvalidationBroker {
suspend fun invalidate(tenantId: String, scope: InvalidationScope)
suspend fun subscribe(handler: suspend (TenantInvalidationEvent) -> Unit)
}

InvalidationScope distinguishes the kinds of invalidation the runtime cares about (tenant routing, public endpoint, config property, IDP registry, integration registry). Replicas subscribe at startup; each component registers its own handler.

Auditing Config Changes

Every typed admin command emits a structured audit event when it writes to tenant_config_property or to the secret backend. The event carries:

  • The tenant id.
  • The acting principal id (from the JWT).
  • The command id (oid4vci.issuer.config.update, oauth2.servers.federation.providers.update, and so on).
  • The property keys touched.
  • For secret writes: the new reference (never the plaintext); for non-secret writes: the new value when the audit policy allows it.

The audit stream is the operator's reconstruction path for "who changed what when". The audit pipeline can replicate to an external SIEM through the SSF transmitter; see the Operations page for the audit pipeline as a whole.