Deployment Topology
The EDK enterprise images are split because the network surface, the access pattern, and the operational lifecycle of each role are genuinely different. The platform container is the central control service. Tenant-KMS is the internal cryptographic authority. Wallet-unit and wallet-interaction provide the internal headless wallet runtime. DID, tenant-AS, issuer, and verifier are tenant runtime services. The admin console is a UI container served on the platform host.
Public Versus Internal Surface
Each container has both a public and an internal surface, but the proportions are very different.
The platform exposes the operator authorization-server protocol surface and the /admin-console UI on platform.<base-domain>. Setup and platform-admin APIs are available only under the platform host and must be protected after first run. The platform service is also the central configuration/control service that other services call internally.
The KMS is never exposed as a raw container endpoint. Issuer, verifier, tenant-AS, and DID call into it across the cluster network. Operators reach KMS administration through the protected tenant gateway route, not by publishing KMS container ports or service DNS names to the internet.
The DID container has public resolver/document routes and protected management routes (did:web publishing, did:webvh log management, manager admin REST, method enable/disable per tenant). Public DID resolution is its primary job, so the deployment template binds the resolver and document paths to the tenant gateway and protects the management paths with bearer-token policy.
The AS, Issuer, and Verifier each have wallet-facing protocol routes and protected tenant-administrator routes. The protocol surface is what wallets and external clients hit (/oid4vci/..., /oid4vp/..., /authorize, /token, /.well-known/...). The admin surface lives under /api/.../v1/... and is meant for tenant operators and the platform admin only. Wallet-unit and wallet-interaction are internal services; customers do not publish their raw container endpoints. The gateway enforces the split: unauthenticated protocol and well-known paths are allowed where the protocol requires them, while administrative paths require a bearer JWT with the right scopes.
Service-to-Service Auth
Once a request lands on the issuer, the verifier, or the AS, those containers may call the platform for tenant/platform configuration, the KMS for signing, the DID resolver for verifying counterparty signatures, or each other for cross-protocol operations. Those calls cross trust boundaries inside the cluster, so they are not unauthenticated.
The Helm chart renders one service-identity contract from serviceIdentity.clientIds, serviceIdentity.serviceIds, and serviceIdentity.audiences. The platform authorization server is the STS for workload tokens. Each runtime service presents its confidential client id to the platform AS, receives a short-lived JWT for the downstream receiver audience, and asserts its workload id as X-Service-Id on the internal command transport.
The receiving service's expected audience and the caller route's requested
audience must resolve to the same serviceIdentity.audiences.<receiver> value.
They are different from the caller registration's
default-access-token-audience and allowed-access-token-audiences: the
default is used only when the caller requests no audience, while the allowlist
contains explicit non-default targets. The client id and asserted service id
form the workload binding.
The Helm-derived caller matrix is:
| Caller | Default target | Allowed additional targets |
|---|---|---|
| tenant-KMS | audiences.platform | none |
| wallet-unit | audiences.platform | none |
| tenant-AS | audiences.platform | audiences.tenant-kms |
| DID | audiences.platform | audiences.tenant-kms |
| issuer | audiences.platform | audiences.tenant-kms, audiences.wallet-interaction |
| verifier | audiences.platform | audiences.tenant-kms, audiences.wallet-interaction |
| wallet-interaction | audiences.platform | audiences.wallet-unit |
The STS accepts an omitted target only with a nonblank default and accepts
exactly one explicit target only when it is the default or an allowed
additional target. Missing defaults, multiple or duplicate targets, and
unregistered targets fail with invalid_target. A route with an explicit
serviceTokenAudience or preferServiceTokenOverSessionBearer=true cannot
fall back to a session bearer, delegation, or anonymous access when workload
token acquisition fails.
The default receiver audiences are:
| Receiver | Audience |
|---|---|
| platform | enterprise-platform |
| tenant-KMS | enterprise-tenant-kms |
| DID | enterprise-tenant-did |
| issuer | enterprise-issuer |
| verifier | enterprise-verifier |
| wallet-unit | enterprise-wallet-unit |
| wallet-interaction | enterprise-wallet-interaction |
Internal identity headers are never credentials by themselves. A receiver may honor X-Tenant-Id or X-Principal-Id only after the JWT validates cryptographically, the token is a workload token, the JWT client id or subject is bound to the asserted X-Service-Id, the token audience matches the receiver, and the receiver trust policy permits the override. Cluster mTLS or NetworkPolicy is still useful transport hardening, but it does not replace JWT validation for these trusted-header paths.
DID, tenant-AS, issuer, and verifier use two distinct JWT audiences when they route KMS work. The inbound token is addressed to the route-only service and is validated there; the KMS route then asks the platform STS for a fresh workload token addressed to the tenant-KMS receiver audience. Issuer and verifier use the same pattern for wallet operations: their wallet route uses an enterprise-wallet-interaction token, and wallet-interaction uses an enterprise-wallet-unit token for policy-gated wallet-key commands. Tenant-AS signing-key provisioning is the strictest case: the platform calls tenant-AS with a short-lived provisioning token addressed to the tenant-AS provisioning endpoint, tenant-AS validates and terminates that token, and the AS-to-KMS hop uses its own tenant-as-service workload token. The provisioning token is never forwarded to tenant-KMS.
Peer Transport
The enterprise images package the generated gRPC client stubs and routed -remote command implementations. Platform, tenant-KMS, wallet-unit, and wallet-interaction run the inbound gRPC receivers. Runtime services route platform-config commands to the platform service, KMS commands to tenant-KMS, and wallet commands to wallet-interaction or wallet-unit over the internal network.
grpc:
enabled: true
port: 9090
authMode: service-jwt
The public gateway never routes gRPC. NetworkPolicy or mesh policy should allow gRPC only on the east-west paths that need it: runtime services to platform, runtime services to tenant-KMS, issuer/verifier to wallet-interaction, wallet-interaction to wallet-unit, and receiver services back to platform as needed. The platform, tenant-KMS, wallet-unit, and wallet-interaction gRPC receivers also enforce their expected JWT audience.
Runtime services bootstrap their east-west wiring from the platform before fetching richer configuration slices. The internal platform.bootstrap.get command and protected /api/platform/bootstrap/v1/consumer-config/{consumerId} REST endpoint return service objects with named endpoints, audiences, config domains, and optional Kubernetes or Docker network addresses. Browser applications use the separate public runtime-config endpoint, which never includes internal service DNS names or container ports.
Platform-owned REST APIs stay on platform.<base-domain>. Tenant-owned APIs such as tenant-KMS and DID stay on <tenant>.<base-domain> or another tenant-bound public endpoint. A frontend proxy path such as /admin-console/api/* is a deployment convenience only; it is not the canonical service owner or API base URL. If the admin-console BFF resolves runtime config through the internal platform service, it forwards the public host and scheme so the returned browser service URLs remain public tenant/platform gateway URLs. The BFF can still call platform-owned and tenant-owned APIs through configured internal service URLs because those calls are server-side and never exposed to the browser. For tenant-owned internal calls, it preserves the public tenant Host and forwarded scheme so service-side tenant resolution does not collapse to the internal Docker or Kubernetes service name.
Data Plane Databases
The enterprise deployment uses two PostgreSQL database roles by default:
- Platform database. The platform service owns this database. It stores first-run setup state, license activation state, the application tenant, tenant registry, routing records, platform configuration, and the platform-only authorization server state.
- Tenant workload database. Tenant-KMS, DID, tenant-AS, issuer, verifier, wallet-unit, and wallet-interaction read and write tenant runtime data here. Isolation is schema-per-tenant: each tenant gets its own schema, for example
tenant_acmeandtenant_globex, and the database router sets the tenant schema on each request.
The platform service may connect to the tenant workload database for tenant onboarding and schema provisioning, but its own platform tables are not co-located with tenant runtime tables. Runtime services do not connect to the platform database.
When a customer requires stronger isolation than schema-per-tenant, the same database routing layer can route selected tenants to a dedicated database or PostgreSQL instance. Connection pooling is per-target via HikariCP. The container code is unchanged; only the routing configuration changes.
Per-tenant configuration that runtime services read through the TenantConfigPropertySource lives in the tenant workload database under the resolved tenant schema. Tenant admins write configuration through the admin REST; runtime services pick it up on the next resolver cache miss and through Postgres LISTEN/NOTIFY invalidation.
Cross-Replica Cache Invalidation
Each runtime container caches the tenant routing table, the per-tenant config, and the public-endpoint bindings in process. With multiple replicas behind a load balancer, a mutation on replica A must be visible on replica B without a restart, or behaviour diverges between replicas.
The EDK solves this through the shared event subsystem: admin commands emit domain events on mutation, a Postgres LISTEN/NOTIFY bridge fans them out to subscribers in every replica, and the local caches invalidate. A TTL fallback covers the case where a notification is missed. The mechanism is the same for tenant routing, public-endpoint bindings, and tenant_config_property updates.
Tenant-Aware Public Endpoint Bindings
A tenant typically owns at least one host that the wallet reaches it at: in the hosted EDK model that is <tenant>.<base-domain>, for example acme.example.com; in a custom-domain model it might be wallet.acme.com. The tenant_public_endpoint row binds a tenant + service type (OID4VCI issuer, OID4VP verifier, OAuth AS, DID resolver) to the host and optional path layout (.well-known path layout, pathPrefix) under which that service is reachable for that tenant.
The metadata that runtime services advertise (the credential_issuer value in OID4VCI metadata, the issuer in OAuth AS metadata, the request_uri_base in an OID4VP authorization request, the status_uri in a credential offer) comes from the tenant public-endpoint binding rather than from the bare request host. This matters in production: when the tenant is reached via a CDN, a reverse proxy, or a custom domain that does not match the cluster's hostname, the metadata still advertises URLs the wallet can actually reach.
The Helm chart's default behaviour is fail-closed: if no tenant_public_endpoint binding exists for the resolved tenant and service type, the runtime service refuses to advertise anything rather than falling back to the request host. The fallback is configurable per environment.
Putting It Together
A canonical small-to-medium deployment runs:
- One platform replica for setup, license activation, operator AS, platform admin, and platform config.
- One replica of each tenant runtime container (tenant-KMS, DID, tenant-AS, issuer, verifier) sized to the workload, on Kubernetes through the
edk-enterpriseHelm chart. - One admin-console replica routed at
platform.<base-domain>/admin-console. - A platform PostgreSQL database for platform state, plus a tenant workload PostgreSQL database using one schema per tenant. Use separate managed instances when operational isolation requires it.
- A public gateway with TLS termination, routing
platform.<base-domain>to the platform and admin-console paths, and<tenant>.<base-domain>to DID, tenant-KMS, tenant-AS, issuer, and verifier by path. Use a wildcard certificate for*.<base-domain>plus the operator host, or automate individual certificates for every tenant host and the operator host. - NetworkPolicy or service-mesh policy that keeps service DNS names, container ports, health routes, metrics, and gRPC east-west only. Customer API access goes through the gateway hostnames.
- A monitoring stack subscribed to
/metricson each container and to the OpenTelemetry collector wired through the EDK telemetry module.
The chart defaults to Nexus image coordinates under nexus.sphereon.com/edk-docker, with the delivered enterprise tag supplied through global.imageTag and registry credentials supplied through global.imagePullSecrets. The KMS service itself remains private even though selected protected REST paths are reachable through the tenant gateway.
For a high-traffic deployment, the issuer, verifier, AS, and DID containers scale horizontally. The KMS scales as well, but more conservatively, most deployments find the bottleneck is the provider backend (AWS KMS rate limits, HSM throughput) rather than the container itself.