IDPartnersidp-auth-peps · reference
gateways/envoy/ · YAML

Envoy, Istio and agentgateway

These gateways have no in-process plugin system. Their authorisation extension point is Envoy's external authorization filter, so the enforcement point is coaz-pep as a gRPC service, and configuring a route means two things: pointing the filter at it, and passing the route's knobs as context_extensions.

There is no screen for this component. Configuration is the gateway's own YAML, shown here in full for each of the three, and coaz-pep's environment, on its page.

How it attaches

client ──▶ gateway ──ext_authz gRPC──▶ coaz-pep ──AuthZEN──▶ PDP
              │                            │
              ▼ PERMIT                     ▼ DENY
        upstream service            401/403 + WWW-Authenticate, or a
                                    JSON-RPC error for MCP — passed through verbatim

One binary serves all three gateways; only the attachment differs. It is a one-to-one port of the Kong plugin, so both send the PDP identical evaluation requests:

Kong pluginEnvoy-family equivalent
per-route plugin configext_authz context_extensions
claim extraction (sub, act.sub, scope, cnf.jkt, acr)same logic, in Go
DPoP sender constraint (RFC 9449)same, verified in process
RFC 9470 step-up challengesEnvoy DeniedHttpResponse: status, WWW-Authenticate and body pass through unchanged
X-Auth-* upstream headersOkHttpResponse.headers

The per-route knobs

Read from context_extensions on every request. Every value is a string, so booleans are "true" and "false". Anything not set takes the default, and a route with no knobs at all is a plain REST resource server with require_token off.

KeyDefaultMeaning
pep_labelcoaz-pepIdentifies this PEP in challenges, logs and X-PDP-PEP.
stylerestrest (resource server) or mcp (MCP edge).
require_tokenfalseDeny without a readable access token.
require_dpopfalseEnforce the RFC 9449 sender constraint on every request. Off, a DPoP-bound token is still checked, and one presented as a bearer token is rejected.
require_user_loginfalseDeny without a valid X-User-Token, with a login challenge.
stepup_scope—Scope demanded for stepup_action.
stepup_actionmake_paymentThe action the step-up applies to.
mcp_upstream_url—The MCP server whose tools/list declares per-tool mappings. Without it every tool call gets the binding's default mapping.
resource—The protected resource's identifier (RFC 8707), the key PDP discovery starts from. MCP routes default to mcp_upstream_url; REST routes without it use the static PDP.
forward_access_tokenfalseSend the raw token to the PDP as context.access_token. Only over a TLS, authenticated PDP connection.
pdp_layersPDP_LAYERSOrdered PDPs to ask, comma-separated: static, resource, or a PDP identifier (which must be on PDP_ALLOWLIST), each optionally suffixed fail-open or fail-closed. Every layer must permit.
fail_modePDP_FAIL_MODEclosed or open for this route's layers that say nothing for themselves. Open marks the permit with X-PDP-Fail-Open; a deny or a refusal never opens.
coaz_defaultstrueDecide every MCP method: a tool's declared mapping, otherwise the binding's default one; an unknown method is denied. "false" keeps the old pass-through for everything but declared tools — an explicit opt-out.
coaz_v2_onlyfalseRefuse tools that declare only the superseded coaz: true mapping.
user_token_subjectprincipalWhose X-User-Token counts: the principal's own login, or with pdp also an approver's, for the PDP to judge with user_sub.
legacy_subject_identitytrueAlso send subject.identity beside subject.id. Set "false" once policies read subject.id.

Plain Envoy

The filter, configured directly, with per-route knobs in ExtAuthzPerRoute. This is a complete listener for two routes: a REST API with discovery, and an MCP edge with COAZ.

static_resources:
  listeners:
    - name: edge
      address: { socket_address: { address: 0.0.0.0, port_value: 8000 } }
      filter_chains:
        - 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: edge
                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        # fail closed
                      with_request_body: { max_request_bytes: 1048576, allow_partial_message: false }
                      grpc_service:
                        envoy_grpc: { cluster_name: coaz_pep }
                        timeout: 2s
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                route_config:
                  virtual_hosts:
                    - name: api
                      domains: ["api.bank.example"]
                      routes:
                        # the resource's public documents: served by coaz-pep, not decided by it
                        - match: { prefix: "/.well-known/oauth-protected-resource" }
                          route: { cluster: coaz_pep_http }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              disabled: true
                        - match: { path: "/.well-known/openid-federation" }
                          route: { cluster: coaz_pep_http }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              disabled: true
                        # the API
                        - match: { prefix: "/" }
                          route: { cluster: bank_api }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              check_settings:
                                context_extensions:
                                  pep_label: "PEP#2 (Bank API edge)"
                                  style: rest
                                  require_token: "true"
                                  require_dpop: "true"
                                  stepup_scope: "banking:payments:transfer"
                                  stepup_action: "make_payment"
                                  resource: "https://api.bank.example"
                                  forward_access_token: "true"
                                  pdp_layers: "https://estate-pdp.bank.example fail-open, resource"
                    - name: mcp
                      domains: ["mcp.bank.example"]
                      routes:
                        - match: { prefix: "/mcp" }
                          route: { cluster: bank_mcp }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              check_settings:
                                context_extensions:
                                  pep_label: "PEP#1 (MCP edge)"
                                  style: mcp
                                  require_token: "true"
                                  require_user_login: "true"
                                  mcp_upstream_url: "http://bank-mcp:8090/mcp"
                                  coaz_defaults: "true"
  clusters:
    - name: coaz_pep
      type: STRICT_DNS
      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: coaz_pep
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: coaz-pep, port_value: 9191 } } } }] }]
    - name: coaz_pep_http
      type: STRICT_DNS
      load_assignment:
        cluster_name: coaz_pep_http
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: coaz-pep, port_value: 9192 } } } }] }]
    - name: bank_api
      type: STRICT_DNS
      load_assignment:
        cluster_name: bank_api
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: bank-api, port_value: 8070 } } } }] }]
    - name: bank_mcp
      type: STRICT_DNS
      load_assignment:
        cluster_name: bank_mcp
        endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: bank-mcp, port_value: 8090 } } } }] }]

Istio

Istio speaks the same gRPC. Four pieces, in order: the deployment and service, a mesh extension provider naming it (which lives in the istio ConfigMap and cannot be a CRD), an AuthorizationPolicy with action: CUSTOM on the workloads to guard, and an EnvoyFilter if a route needs knobs. This is gateways/envoy/istio/coaz-pep.yaml from the repository.

# kubectl -n istio-system edit configmap istio   (under data.mesh)
extensionProviders:
  - name: coaz-pep
    envoyExtAuthzGrpc:
      service: coaz-pep.authz.svc.cluster.local
      port: 9191
      includeRequestBodyInCheck:
        maxRequestBytes: 1048576
        allowPartialMessage: false
apiVersion: v1
kind: Namespace
metadata:
  name: authz
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coaz-pep
  namespace: authz
  labels: { app: coaz-pep }
spec:
  replicas: 2 # fail-closed puts this in the request path: do not run one
  selector:
    matchLabels: { app: coaz-pep }
  template:
    metadata:
      labels: { app: coaz-pep }
    spec:
      containers:
        - name: coaz-pep
          image: ghcr.io/id-partners/coaz-pep:0.4.0 # pin a release, never :latest
          ports:
            - { name: grpc, containerPort: 9191 }
            - { name: http, containerPort: 9192 }
          env:
            # coaz-pep will not start without these — see its page
            - name: AUTHZEN_URL
              value: http://authzen-adapter.authz.svc.cluster.local:8080
            - name: AUTHZEN_API_KEY
              valueFrom:
                secretKeyRef: { name: coaz-pep, key: authzen-api-key }
            - name: CHECK_API_TOKEN
              valueFrom:
                secretKeyRef: { name: coaz-pep, key: check-api-token }
            - name: MCP_UPSTREAM_ALLOWLIST
              value: http://bank-mcp.default.svc.cluster.local:8090
            - name: ACCESS_TOKEN_JWKS_URL
              value: https://as.example.com/.well-known/jwks.json
            - name: ACCESS_TOKEN_ISSUER
              value: https://as.example.com
            - name: ACCESS_TOKEN_AUDIENCE
              value: https://api.example.com
            - name: USER_TOKEN_AUDIENCE
              value: banking-app
          readinessProbe:
            httpGet: { path: /readyz, port: http }
          livenessProbe:
            httpGet: { path: /healthz, port: http }
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 256Mi }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
  name: coaz-pep
  namespace: authz
spec:
  selector: { app: coaz-pep }
  ports:
    - { name: grpc, port: 9191, targetPort: 9191, appProtocol: grpc }
    - { name: http, port: 9192, targetPort: 9192 }
---
# Guard one workload. `action: CUSTOM` hands the decision to the named provider;
# `rules` narrows WHICH requests are sent for a decision, it does not decide them.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: bank-api-authzen
  namespace: default
spec:
  selector:
    matchLabels: { app: bank-api }
  action: CUSTOM
  provider:
    name: coaz-pep
  rules:
    - to:
        - operation:
            notPaths: ['/healthz', '/metrics']
---
# Per-route knobs. AuthorizationPolicy has no field for context_extensions, so with the
# three resources above every request arrives with the defaults: style=rest,
# require_token=false, no COAZ. Enough for a REST service whose tokens a
# RequestAuthentication validates; NOT enough for an MCP edge.
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: coaz-pep-context-mcp
  namespace: default
spec:
  workloadSelector:
    labels: { app: bank-mcp }
  configPatches:
    - applyTo: HTTP_ROUTE
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          vhost:
            route:
              name: default
      patch:
        operation: MERGE
        value:
          typed_per_filter_config:
            envoy.filters.http.ext_authz:
              '@type': type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
              check_settings:
                context_extensions:
                  pep_label: 'PEP#1 (MCP edge)'
                  style: mcp
                  require_token: 'true'
                  require_dpop: 'false'
                  require_user_login: 'true'
                  mcp_upstream_url: http://bank-mcp.default.svc.cluster.local:8090/mcp

The trap. AuthorizationPolicy carries no knobs, so without the EnvoyFilter every request reaches the PEP with defaults. Fine for a plain REST service. Not fine for an MCP edge, where style and mcp_upstream_url have no sensible default. Either patch them in as above, or give the MCP edge its own provider pointing at its own coaz-pep service.

agentgateway

agentgateway (solo.io) attaches the same service through its extAuthz policy, and the per-route context map is delivered as context_extensions. This is the repository's gateways/envoy/agentgateway/config.yaml.template: one gateway fronting an MCP edge and a bank API, each with its own PEP label. The __PLACEHOLDER__ tokens are substituted at container start by docker-entrypoint.sh, so the same image runs in compose and on Railway.

binds:
- port: 8000
  listeners:
  - protocol: HTTP
    routes:
    - name: mcp-route
      matches:
      - path:
          pathPrefix: /mcp
      policies:
        extAuthz:
          host: __EXTAUTHZ_HOST__          # coaz-pep:9191
          failureMode: deny               # fail closed if the PEP is unreachable
          includeRequestBody:
            maxRequestBytes: 1048576      # the PEP decides every JSON-RPC message
            allowPartialMessage: false    # never a truncated body
          protocol:
            grpc:
              context:
                pep_label: "PEP#1 (MCP edge)"
                style: "mcp"
                require_token: "true"
                require_dpop: "false"
                require_user_login: "true"
                mcp_upstream_url: "http://__BANK_MCP_HOST__/mcp"
      backends:
      - host: __BANK_MCP_HOST__

    - name: bank-route
      matches:
      - path:
          pathPrefix: /bank
      policies:
        extAuthz:
          host: __EXTAUTHZ_HOST__
          failureMode: deny
          includeRequestBody:
            maxRequestBytes: 1048576      # the PEP inspects payment bodies
            allowPartialMessage: false
          protocol:
            grpc:
              context:
                pep_label: "PEP#2 (Bank API edge)"
                style: "rest"
                require_token: "true"
                require_dpop: "false"
                stepup_scope: "banking:payments:transfer"
                stepup_action: "make_payment"
        urlRewrite:
          path:
            prefix: /                     # /bank/payments -> /payments upstream
      backends:
      - host: __BANK_API_HOST__

The well-known documents

When coaz-pep is the federation entity for the resource it fronts (FEDERATION_ENTITY_ID on its page), it serves {path}/.well-known/openid-federation and /.well-known/oauth-protected-resource{path} on its HTTP port. Route those two paths, on the resource's host, to that port as an ordinary upstream with the ext_authz filter disabled for them, as the plain Envoy sample does. They are public documents; the trust controller fetches the first to onboard the resource.

Whole bodies only

Set allow_partial_message: false. coaz-pep decides every MCP message and reads payment bodies, so it must see the body the upstream will. With partial messages allowed, a body over the limit reaches the PEP truncated while the upstream receives all of it — the PEP refuses a body Envoy marks partial with a 413, but the right configuration never sends one. Set max_request_bytes above your largest legitimate request, and leave pack_as_bytes as you like: the PEP reads whichever field the body arrives in.

The sample manifest in gateways/envoy/istio/coaz-pep.yaml also carries a PodDisruptionBudget, topology spread, a 30 second grace period for the SIGTERM drain, and a NetworkPolicy that lets only the gateways reach :9191 — a caller there chooses the route's knobs.

Failure mode

Always fail closed at the gateway. failure_mode_allow: false in Envoy, failureMode: deny in agentgateway, and Istio's CUSTOM action already denies when the provider is unreachable. A PEP that fails open at the gateway is not a PEP. The place to decide that an individual policy layer may be skipped when its PDP is down is coaz-pep's pdp_layers and fail_mode, which mark every such permit with X-PDP-Fail-Open; the gateway's own failure mode is about coaz-pep itself being unreachable, and that must deny.