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

Configuration & Secrets

The EDK enterprise containers share a configuration model with mounted YAML, environment variables, platform configuration, and per-tenant configuration stored in the tenant workload database. Secrets are kept out of those layers through secret-reference interpolation, with the actual values resolved at runtime from the configured secret backend.

The Layered Configuration Model​

Configuration in an EDK container is the result of a chain of PropertySource instances composed by the IDK config system and enriched by the EDK overlay:

  1. Shipped application.yaml. Lives at /app/config/application.yaml inside the image. Carries safe defaults for local testing.
  2. Mounted YAML overlays. A deployment mounts additional YAML files (or replaces the shipped one) at known paths under /app/config/. Files are merged in lexical order.
  3. Environment variables. Standard EDK env-var mapping translates OAUTH2__SERVERS__ACME__ISSUER_URI into the oauth2.servers.acme.issuer_uri property.
  4. Cloud config providers. Optional Azure App Configuration or REST Config providers are wired through lib-conf-azure-appconfig or lib-conf-rest-config. Both layer in as standard property sources.
  5. Platform bootstrap and config services. Runtime services first resolve bootstrap metadata from the platform service, then resolve platform-owned configuration slices through the same platform route. In the enterprise deployment these peer calls are internal and normally use gRPC to enterprise-platform:9090.
  6. Per-tenant workload-database config. The TenantConfigPropertySource reads tenant_config_property rows from the schema for the currently resolved tenant in the tenant workload database. This is the layer that tenant administrators write through platform administration and configuration APIs.

Resolution within a request is App -> Tenant -> Principal (the three ConfigService scopes). A property that exists 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.

The Shipped application.yaml​

The application.yaml shipped in each container is deliberately minimal and permissive, for local testing:

server:
rest:
port: 8080
auth:
enabled: false
anonymous:
allowed: true

It ships permissive rather than locked-down so a first docker run reaches the health endpoint without configuring JWT issuance up front. A production deployment overrides server.rest.auth to enabled: true and configures the JWT issuer the admin REST validates bearer tokens against.

Layered onto this, a typical production overlay sets:

  • The PostgreSQL JDBC URLs and credentials for the platform database and the tenant workload database (referenced through secret interpolation).
  • The KMS endpoint and the service-JWT key alias the container uses to authenticate to it.
  • The tenant resolution settings: the platform base host, the trusted reverse-proxy hop count, the cache TTL.
  • The container-specific routing: the AS issuer URL and JWKS for the issuer container, the trust source registry refresh schedule for the verifier container, the DID method allowlist for the DID container.

Environment Variables​

An environment variable name is the property key in upper case with every . and - replaced by an underscore. database.url becomes DATABASE_URL, and secret-management.authority.tenant-policy.allow-tenant-managed-providers becomes SECRET_MANAGEMENT_AUTHORITY_TENANT_POLICY_ALLOW_TENANT_MANAGED_PROVIDERS. The resolver treats ., -, _ and repeated underscores alike, so DATABASE__URL sets the same key. The same folding means an environment variable cannot express a key segment that must keep its hyphens, such as a provider id inside a key path; set those in a mounted YAML file and supply the values with ${env:NAME} placeholders.

Two env vars are read directly by the container startup code and never via the property resolver, because they need to be available before the DI graph is composed:

  • APP_PROFILE: the profile name passed into the Metro app graph factory. Used by config sources that key on profile.
  • PORT: the HTTP listen port. Defaults to 8080 if not set.

The Helm chart sets the standard image and platform values under global and injects per-service environment overrides through services.<role>.env:

global:
imageRegistry: nexus.sphereon.com/edk-docker
imageTag: "<enterprise-version>"
imagePullPolicy: IfNotPresent
imagePullSecrets: []
platformBaseDomain: example.com

services:
issuer:
env:
- name: APP_PROFILE
value: production

Use the exact global.imageTag supplied through your OEM, MSP, or EDK distribution channel. The tag is the product version for that delivery; the source revision is reported separately through image metadata and /version. Use global.imagePullSecrets for authenticated registry pulls. Do not put Docker credentials in services.<role>.env.

Secret References​

Kubernetes deployment bootstrap Secrets​

The Helm chart needs two deployment bootstrap values before application-level secret providers can initialize:

serviceIdentity:
internalClientExistingSecret: edk-runtime-secrets
internalClientSecretKey: internal-client-secret
keystore:
existingSecret: edk-runtime-secrets
passwordKey: keystore-password

edk-runtime-secrets is an example Kubernetes Secret name. Its internal-client-secret value is the confidential-client credential used by satellites to obtain platform-issued workload tokens. Its keystore-password value protects the platform and tenant-KMS software PKCS#12 stores. Generate the values independently, keep them out of Git and values files, and have the cluster's secret-management mechanism create the Secret in the release namespace. These values are injected with Kubernetes secretKeyRef; they are not ${secret:...} property-source references.

Helm validates the configured names. Kubernetes validates the Secret object and key names when a container starts. Missing objects and keys therefore surface as CreateContainerConfigError pod events. Restart affected Deployments after rotating data under an existing Secret name.

Application-level secret providers​

Secrets in YAML and env vars are stored as references, never as plaintext, through the EDK secret-interpolation syntax:

database:
url: jdbc:postgresql://postgres:5432/edk
username: edk_app
password: ${secret:vault:edk/postgres/app-password}

${secret:vault:...} is resolved at startup by the configured SecretProvider SPI implementation. The EDK ships three production-grade implementations:

  • HashiCorp Vault. Configured against any KV v2 secret engine. Authentication via AppRole, Kubernetes auth, or token. Module: lib-conf-vault.
  • AWS Secrets Manager. Configured against the regional Secrets Manager endpoint. Authentication via the AWS credential chain (IAM role for service accounts on EKS, environment, profile). Module: lib-conf-aws-secrets.
  • Azure Key Vault. Configured against a vault URL. Authentication via the Azure credential chain (managed identity, service principal, environment). Module: lib-conf-azure-keyvault.

When the deployment standardises on Kubernetes secrets, secrets are mounted as files and referenced through the ${file:...} interpolation rather than ${secret:...}. The result is the same: no plaintext secret in the YAML, no plaintext secret in the env var, no plaintext secret in the image.

A deployment may have multiple secret providers wired simultaneously, distinguished by the prefix after secret: (secret:vault:, secret:aws:, secret:azure:). Per-tenant secret backend selection is supported through the TenantConfigSecretClassifier.

Per-Tenant Configuration in the Workload Database​

Per-tenant configuration lives in tenant_config_property inside the resolved tenant's schema in the tenant workload database. Each row is (tenant_id, key, value, secret_reference, updated_at). The runtime reads these rows through TenantConfigPropertySource when a tenant is in scope on the call.

Tenant administrators write configuration through typed REST surfaces rather than a raw JSONB endpoint. Issuer, verifier, DID method, trust source, integration, and webhook settings may project governed values into tenant_config_property. Authorization servers are different: the UUID resource, lifecycle, discovery snapshot, federation bindings, clients, identities, signing references, and OID4VCI bindings are durable records in the service catalog. Only the hosted runtime projection uses the oauth2.servers.* configuration namespace, and that projection is never lifecycle authority.

Secret values written through the admin REST never land in tenant_config_property as plaintext. The TenantConfigSecretClassifier recognises secret-bearing properties and persists only a secret reference; the actual value lands in the configured secret backend under a key path that includes the tenant id.

Cross-replica invalidation when a tenant administrator updates configuration goes through the shared event subsystem: the admin command emits an application.tenant.config-updated event, a Postgres LISTEN/NOTIFY bridge on the tenant workload database fans out, and each replica's TenantConfigPropertySource cache invalidates for the affected tenant. A TTL fallback covers missed notifications.

Tenant Resolution Settings​

The tenant resolution stack reads its settings from the top of the property resolver chain (the app-scope, not per-tenant, because tenant resolution runs before any tenant is in scope). The relevant properties:

  • tenant.resolution.platform_base_host: the host suffix that subdomain resolution treats as the platform base. Subdomains of this resolve to tenant slugs.
  • tenant.resolution.trusted_proxy_hop_count: how many X-Forwarded-Host hops the resolver trusts. Important behind reverse proxies and CDNs.
  • tenant.resolution.cache_ttl_seconds: fallback TTL on the in-memory tenant routing cache. Cross-replica invalidation handles the typical case; the TTL covers missed notifications.
  • tenant.resolution.well_known_path_modes: which .well-known URL forms each protocol supports (spec form only, spec + legacy, or custom). The defaults match the discovery URL forms described in the topology page.

The TenantResolutionSettingsBinder reads these properties and feeds them into the Ktor TenantResolutionPlugin at server startup.

Database Routing​

The enterprise deployment has two database roles. The platform service owns the platform database. Tenant-KMS, DID, tenant-AS, wallet-unit, wallet-interaction, issuer, and verifier use the tenant workload database, with schema-per-tenant isolation:

database:
platform:
url: jdbc:postgresql://platform-postgres:5432/edk_platform
username: edk_platform
password: ${secret:vault:edk/postgres/platform/password}
tenant:
url: jdbc:postgresql://tenant-postgres:5432/edk_tenant
username: edk_tenant
password: ${secret:vault:edk/postgres/tenant/password}
isolation: schema
schemaPattern: tenant_{id}

At tenant registration time, the platform provisions the workload schema for the tenant. A request bound to the acme tenant routes repository calls to the tenant workload database with the search path set to that tenant's schema. Replicas of a runtime service share the same workload database and route by the resolved tenant.

When a tenant needs stronger isolation than schema-per-tenant, the lib-data-store-db-routing-config and lib-data-store-db-routing-pooling modules can route that tenant to a dedicated database or PostgreSQL instance. Connection pooling is per-target via HikariCP, and routing changes take effect on the next resolver cache miss and cross-replica invalidation without restarting the container.

Service-to-Service Auth Configuration​

Internal service calls use short-lived workload JWTs issued by the platform authorization server. Each satellite authenticates as a registered confidential client using the shared runtime Secret, then sends the resulting bearer to the intended receiver audience. The service client id, asserted service id, and receiver audience are one contract:

serviceIdentity:
internalClientExistingSecret: edk-runtime-secrets
clientIds:
tenant-as: tenant-as-service
tenant-kms: kms-service
issuer: issuer-service
serviceIds:
tenant-as: service-tenant-as
tenant-kms: service-crypto
issuer: service-oid4vci
audiences:
platform: enterprise-platform
tenant-kms: enterprise-tenant-kms
issuer: enterprise-issuer

Receivers validate the platform-issued JWT, workload-token class, client-to-service binding, and audience before trusted tenant or principal headers can affect request context. grpc.authMode=service-jwt uses this in-process contract. grpc.authMode=mesh-mtls delegates peer authentication to the service mesh and requires the matching mesh policy on both caller and receiver.

The accepted values are service-jwt, mtls, mesh-mtls and none, and they are parsed fail closed. A blank or misspelled value is a startup failure, not a silent fall back to a default, so a typo in a Helm value or a Compose environment variable stops the container instead of quietly changing the security posture.

mtls additionally refuses to start plaintext. It requires serverCert, serverKey and clientCaCert; if any of the three is missing the server throws gRPC authMode=MTLS requires serverCert, serverKey, and clientCaCert; refusing plaintext fallback. service-jwt and none may start plaintext, because their peer authentication does not depend on the transport.

Each receiver logs its resolved posture on startup as gRPC transport authMode=<mode> tls=<on|off> mesh=<true|false>. Read that line first when diagnosing an east-west call that is rejected before it reaches a command.

The audience controls have separate jobs. A receiver expected audience is serviceIdentity.audiences.<receiver>. A caller's route-requested audience is its effective serviceTokenAudience. The platform AS internal-client key default-access-token-audience supplies a target only when the client_credentials request omits one; allowed-access-token-audiences contains the permitted explicit non-default targets. The caller's client id is also bound to its asserted service id.

The chart derives this registration matrix from the serviceIdentity maps:

CallerClient/service bindingDefaultAllowed additional audiences
tenant-KMSclientIds.tenant-kms / serviceIds.tenant-kmsaudiences.platformnone
wallet-unitclientIds.wallet-unit / serviceIds.wallet-unitaudiences.platformnone
tenant-ASclientIds.tenant-as / serviceIds.tenant-asaudiences.platformaudiences.tenant-kms
DIDclientIds.did / serviceIds.didaudiences.platformaudiences.tenant-kms
issuerclientIds.issuer / serviceIds.issueraudiences.platformaudiences.tenant-kms, audiences.wallet-interaction
verifierclientIds.verifier / serviceIds.verifieraudiences.platformaudiences.tenant-kms, audiences.wallet-interaction
wallet-interactionclientIds.wallet-interaction / serviceIds.wallet-interactionaudiences.platformaudiences.wallet-unit

An audience-free client-credentials request requires a nonblank default. One explicit audience is allowed only when it is the default or an allowed additional target. Missing defaults, multiple or duplicate targets, and unregistered targets return HTTP 400 invalid_target. An explicit route serviceTokenAudience or preferServiceTokenOverSessionBearer=true is fail-closed: a missing service token cannot fall back to the session bearer, delegation, or anonymous access. A partial server.service-identity is also a configuration error.

JWKS Fetch Transport Rules​

Remote JWKS resolution requires HTTPS. Plain HTTP is retained only for in-network fetches, and the accepted host shapes are deliberately narrow:

  • RFC 6761 loopback names.
  • Single-label service names with no dot, which is what Compose and Kubernetes Service DNS produce inside a namespace.
  • Names ending in .svc, .svc.cluster.local or .cluster.local.

Everything else over HTTP is refused. That includes .local, .internal (cloud metadata endpoints live there), and RFC 1918 and link-local IP literals such as 10.0.0.1, 192.168.1.1, 172.16.0.1 and 169.254.169.254. The refusal is an SSRF control: a tenant-supplied issuer must not be able to steer a platform service at an internal address.

If an upgrade breaks a JWKS fetch that used to work, the URL was almost certainly an HTTP .local or .internal name. Move it to HTTPS or to a cluster-local Service FQDN. Public gateway hosts were and remain HTTPS-only.

Peer Transport Configuration​

Peer transport is separate from peer authentication. The enterprise images include generated gRPC client stubs and routed remote command dependencies. Platform, tenant-KMS, wallet-unit, and wallet-interaction run the inbound gRPC receivers; the other runtime services use outbound routes to them:

grpc:
enabled: true
port: 9090
authMode: service-jwt

Keep gRPC east-west only. Runtime services route platform configuration commands to the platform service, KMS commands to tenant-KMS, and wallet commands to wallet-interaction or wallet-unit. The public gateway routes only HTTPS REST/protocol traffic. Platform, tenant-KMS, wallet-unit, and wallet-interaction receivers validate the workload JWT audience before any trusted internal tenant or principal header can affect request context.

Configuration Hot-Reload​

The boundary for hot-reload is the property source. Property sources that support change notifications (TenantConfigPropertySource, the Azure App Configuration provider, the REST config provider) propagate changes into the running container without a restart. The shipped application.yaml, mounted YAML files, and environment variables are read at startup only.

Tenant administrators changing per-tenant config through the admin REST is the most common hot-reload path. App-level config changes typically require a rolling restart of the affected container.

Bootstrap Runtime Config​

The platform exposes a narrow bootstrap projection for values needed before a service or browser app can call the richer APIs:

  • satellites use the internal platform.bootstrap.get command, or the protected GET /api/platform/bootstrap/v1/consumer-config/{consumerId} REST endpoint, for platform metadata, service endpoint objects, service identity hints, audiences, config domains, license role/capabilities, telemetry hints, revision, TTL, and diagnostics. This internal projection may include Kubernetes or Docker service DNS names, ports, namespaces, and gRPC URLs;
  • browser apps call GET /api/platform/bootstrap/v1/runtime-config/{applicationId} for an allowlisted public projection. The REST response is shaped as { metadata, data }; browser service wiring lives under data.services, where each service owns its baseUrl, optional audience, and named endpoints. The current application ids are admin-console, platform-onboarding, and license-portal.

Browser runtime config derives the platform public base URL from the incoming request origin and forwarded headers unless an explicit platform external base URL is configured. Platform APIs remain on the platform origin, for example https://platform.<base-domain>/api/platform/admin/v1. Tenant APIs are not platform APIs: tenant-KMS and DID browser URLs resolve to the tenant origin, for example https://<tenant>.<base-domain>/api/kms/v1 and https://<tenant>.<base-domain>/api/did/v1, or are omitted until a tenant selector/public base is known. An admin-console /admin-console/api/* proxy is only an explicit frontend BFF deployment mode, not the canonical service topology.

In BFF mode the admin console may fetch runtime config through the internal platform URL, but it must forward the browser-visible public origin with X-Forwarded-Host and X-Forwarded-Proto. The BFF also uses server-only internal upstreams for platform-admin, platform-config, token, setup-status, tenant-KMS, DID, issuer-owned, and verifier-owned API calls. For tenant-owned internal calls, the BFF still preserves the public tenant Host and forwarded scheme so tenant resolution and generated links remain tenant-bound. Compose and Helm set these as ADMIN_CONSOLE_PLATFORM_BASE_URL, ADMIN_CONSOLE_TENANT_KMS_BASE_URL, ADMIN_CONSOLE_TENANT_DID_BASE_URL, ADMIN_CONSOLE_ISSUER_BASE_URL, and ADMIN_CONSOLE_VERIFIER_BASE_URL. The runtime-config baseUrl values still stay public (platform.<base-domain> and <tenant>.<base-domain>); Docker or Kubernetes service DNS names belong only in server-side BFF/consumer-config/bootstrap projections.

This projection is intentionally not a replacement for platform config or the domain APIs. Service definitions and service runtime settings remain in Platform Config (/api/platform/config/v1). Credential designs, issuer and verifier designs, render variants, status-list policy artifacts, DCQL query bodies, and verifier presentation definitions remain in their dedicated credential-design, issuer, verifier, and DCQL APIs. Platform config may reference those resources by id/version, but bootstrap never carries their bodies.

The public bootstrap path is anonymous because it is needed before browser sign-in, but its payload is browser-safe only: no secrets, database coordinates, secret-backend paths, KMS credentials, internal-only URLs, or unrestricted config keys. Everything beyond that projection requires a bearer or an internal workload token.

Protocol metadata is the exception to request-origin derivation. OAuth/OIDC, OID4VCI, OID4VP, DID, and other .well-known documents must continue to advertise the canonical public endpoint binding for the resolved tenant/service rather than blindly echoing the request host.