Envoy
Envoy terminates mTLS against the client CA bundle that mt-enforcer keeps current over file-based SDS, and asks the enforcer about every request through the ext_authz gRPC filter with the peer certificate attached.
Start the enforcer
mt-enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer
# shows a pairing code; approve it in the portal (Services > your service > Pair gateway)mt-enforcer envoy-grpc --state /var/lib/mt-enforcer \
--listen 127.0.0.1:9191 \
--envoy-sds-out /var/run/mt/sds/client-ca.yamlStart Envoy after the enforcer is ready (/readyz returns 200) so the SDS file exists. The enforcer replaces it by atomic rename whenever the bundle changes, and Envoy reloads it through the watched directory.
Envoy configuration
# Mutual Trust with Envoy (tested with Envoy 1.40.2).
#
# Envoy terminates mTLS. The client CA bundle comes from the signed policy
# manifest: mt-enforcer writes it as a file-based SDS resource and swaps it by
# atomic rename, which Envoy picks up through the watched directory. Every
# request is then authorized by mt-enforcer over the ext_authz gRPC API with
# the peer certificate attached (include_peer_certificate). The enforcer
# re-validates the leaf against the same bundle and answers allow (setting
# x-mt-principal, x-mt-grant and x-mt-request-id upstream, removing any other
# inbound x-mt-*) or deny (403 with a JSON problem). Envoy fails closed when the
# enforcer is unreachable (failure_mode_allow: false).
#
# Start the enforcer first so the SDS file exists:
# mt-enforcer envoy-grpc --state /var/lib/mt-enforcer --listen 127.0.0.1:9191 \
# --envoy-sds-out /var/run/mt/sds/client-ca.yaml
node: { id: mt-gateway, cluster: mt-gateway } # required for file-based SDS
admin:
address: { socket_address: { address: 127.0.0.1, port_value: 9901 } }
static_resources:
listeners:
- name: mtls_ingress
address: { socket_address: { address: 0.0.0.0, port_value: 8443 } }
filter_chains:
- transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_params: { tls_minimum_protocol_version: TLSv1_2 }
alpn_protocols: [h2, http/1.1]
tls_certificates: # your server certificate
- certificate_chain: { filename: /etc/envoy/certs/server.pem }
private_key: { filename: /etc/envoy/certs/server-key.pem }
validation_context_sds_secret_config: # client CA bundle from the signed manifest
name: mt_client_ca
sds_config:
resource_api_version: V3
path_config_source:
path: /var/run/mt/sds/client-ca.yaml
watched_directory: { path: /var/run/mt/sds }
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: mt_ingress
forward_client_cert_details: SANITIZE # never pass a client-supplied XFCC upstream
common_http_protocol_options:
max_connection_duration: 300s # maximum stream age, matching the enforcer
route_config:
name: api
virtual_hosts:
- name: api
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: origin, timeout: 30s }
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
transport_api_version: V3
failure_mode_allow: false # enforcer unreachable: deny
include_peer_certificate: true # attributes.source.certificate
status_on_error: { code: ServiceUnavailable }
grpc_service:
envoy_grpc: { cluster_name: mt_enforcer }
timeout: 0.5s
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: mt_enforcer
type: STATIC
connect_timeout: 0.25s
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config: { http2_protocol_options: {} }
load_assignment:
cluster_name: mt_enforcer
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: 127.0.0.1, port_value: 9191 } } }
- name: origin # reachable only from Envoy (private network)
type: STRICT_DNS
connect_timeout: 1s
load_assignment:
cluster_name: origin
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: origin, port_value: 9001 } } }How the pieces fit
| Setting | Why |
|---|---|
require_client_certificate: true | Envoy proves possession of the client key and validates the chain against the SDS bundle. |
validation_context_sds_secret_config | The bundle comes from the signed manifest, swapped atomically. |
include_peer_certificate: true | The leaf reaches the enforcer in attributes.source.certificate; the enforcer re-validates it and applies the policy. |
failure_mode_allow: false | If the enforcer is unreachable the request is denied. |
forward_client_cert_details: SANITIZE | A client-supplied x-forwarded-client-cert never reaches the origin. |
max_connection_duration: 300s | Long-lived connections are recycled, matching the enforcer. |
Response contract
On allow the enforcer returns an OkHttpResponse that sets x-mt-principal, x-mt-grant, x-mt-request-id and x-mt-policy-revision upstream (overwriting client values) and lists every other inbound x-mt-* header in headers_to_remove. On deny it returns a DeniedHttpResponse with status 403, the JSON problem body and x-mt-reason. The gRPC health service reports SERVING only while policy is fresh.
Envoy's HTTP ext_authz filter works too: run mt-enforcer forward-auth --format xfcc with --path-prefix matching the filter's path_prefix, and set forward_client_cert_details: SANITIZE_SET with set_current_client_cert_details: { cert: true }.
Run with Docker Compose
# Envoy + mt-enforcer (ext_authz gRPC). Envoy shares the enforcer's network
# namespace, so the shipped envoy.yaml reaches it on 127.0.0.1:9191 exactly as
# it would as a sidecar in a Kubernetes pod.
#
# docker compose run --rm enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer
# docker compose up -d
#
# Put your server certificate in ./certs/server.pem and ./certs/server-key.pem.
services:
enforcer:
build: { context: ../.., dockerfile: Dockerfile }
image: mt-enforcer:1.0.0
command: ["envoy-grpc", "--state", "/var/lib/mt-enforcer", "--listen", "127.0.0.1:9191",
"--admin", "0.0.0.0:9464", "--envoy-sds-out", "/var/run/mt/sds/client-ca.yaml"]
ports: ["8443:8443", "9464:9464"]
volumes: ["mt-state:/var/lib/mt-enforcer", "mt-sds:/var/run/mt/sds"]
healthcheck:
test: ["CMD", "/mt-enforcer", "healthcheck"]
interval: 5s
retries: 30
envoy:
image: envoyproxy/envoy:v1.40.2@sha256:8de97e654644827d62afdbf7e1d3dc9fee01a8690d410dd1c63a30dc6b6cecbb
network_mode: "service:enforcer"
depends_on: { enforcer: { condition: service_healthy } }
command: ["envoy", "-c", "/etc/envoy/envoy.yaml"]
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml:ro
- ./certs:/etc/envoy/certs:ro
- mt-sds:/var/run/mt/sds:ro
origin:
image: ${ORIGIN_IMAGE:-ghcr.io/your-org/orders-api:latest} # your API; never published on the host
volumes: { mt-state: {}, mt-sds: {} }Verify
mt enroll --token <token from portal> --api https://api.mutualtrust.io
mt call https://orders-api.example.com:8443/orders/1
# 200, and your origin receives x-mt-principal: urn:mutualtrust:workload:...
mt call https://orders-api.example.com:8443/admin/export
# 403 {"code":"route_not_permitted", ...}
# any other client: the files are in the workload directory that mt status prints
W="$(dirname "$(mt status --json | jq -r .files.chain)")"
curl --cert "$W/chain.pem" --key "$W/key.pem" https://orders-api.example.com:8443/orders/1Tested