Start the enforcer

Pair the enforcer (once)
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 forward-auth (kong)
mt-enforcer forward-auth --format kong --state /var/lib/mt-enforcer \ --listen 127.0.0.1:9191 --bundle-out /etc/mt/client-ca.pem

Plugin

services/enforcer/integrations/kong/plugins/mt-enforcer/handler.lua
-- mt-enforcer: Kong Gateway plugin that asks Mutual Trust's enforcer -- (mt-enforcer forward-auth --format kong) about every request. -- -- Kong terminates mTLS (see kong.conf / docker-compose.yaml for the proxy -- listener's client-certificate settings). The plugin removes every inbound -- x-mt-* header, sends the certificate of this connection and the original -- request line to the enforcer in a request built from scratch, and either sets -- the verified identity on the upstream request or returns the enforcer's denial. -- If the enforcer cannot be reached the request is refused (fail closed). local http = require "resty.http" local MtEnforcer = { PRIORITY = 1600, -- before authentication and transformation plugins VERSION = "1.0.0", } local function header(res, name) local v = res.headers[name] if type(v) == "table" then return v[1] end return v end -- Method and URL overrides would make the origin run a request other than the one authorized. local overrides = { ["x-http-method-override"] = true, ["x-http-method"] = true, ["x-method-override"] = true, ["x-original-url"] = true, ["x-rewrite-url"] = true, ["x-original-method"] = true, ["x-original-uri"] = true, } function MtEnforcer:access(conf) for name, _ in pairs(kong.request.get_headers(200)) do local lower = string.gsub(string.lower(name), "_", "-") if string.sub(lower, 1, 5) == "x-mt-" or lower == "x-client-cert" or overrides[lower] then kong.service.request.clear_header(name) end end local headers = { ["X-Original-Method"] = kong.request.get_method(), ["X-Original-URI"] = ngx.var.request_uri, } local cert = ngx.var.ssl_client_escaped_cert -- URL-encoded PEM leaf of this connection if cert ~= nil and cert ~= "" then headers["X-Client-Cert"] = cert end local traceparent = kong.request.get_header("traceparent") if traceparent ~= nil then headers["Traceparent"] = traceparent end local httpc = http.new() httpc:set_timeout(conf.timeout) local res, err = httpc:request_uri(conf.enforcer_url, { method = "GET", headers = headers, keepalive_timeout = 60000, keepalive_pool = 16, }) if not res then kong.log.err("mutual trust enforcer unreachable: ", err) return kong.response.exit(503, { code = "enforcer_unavailable" }) end local request_id = header(res, "X-Mt-Request-Id") if res.status ~= 200 then return kong.response.exit(res.status == 401 and 401 or 403, res.body, { ["Content-Type"] = "application/json", ["X-Mt-Request-Id"] = request_id, ["X-Mt-Reason"] = header(res, "X-Mt-Reason"), }) end local principal = header(res, "X-Mt-Principal") if principal == nil or principal == "" then return kong.response.exit(503, { code = "enforcer_response_invalid" }) end kong.service.request.set_header("X-Mt-Principal", principal) kong.service.request.set_header("X-Mt-Grant", header(res, "X-Mt-Grant")) kong.service.request.set_header("X-Mt-Request-Id", request_id) kong.response.set_header("X-Mt-Request-Id", request_id) kong.response.set_header("X-Mt-Policy-Revision", header(res, "X-Mt-Policy-Revision")) end return MtEnforcer
services/enforcer/integrations/kong/plugins/mt-enforcer/schema.lua
local typedefs = require "kong.db.schema.typedefs" return { name = "mt-enforcer", fields = { { protocols = typedefs.protocols_http }, { config = { type = "record", fields = { { enforcer_url = typedefs.url({ required = true, default = "http://127.0.0.1:9191/authz" }) }, { timeout = { type = "integer", default = 1000, between = { 1, 10000 } } }, }, } }, }, }

Declarative configuration

services/enforcer/integrations/kong/kong.yml
# Mutual Trust with Kong Gateway (DB-less declarative configuration). # The mt-enforcer plugin runs on every route of the protected service. _format_version: "3.0" services: - name: orders-api url: http://origin:9001 # private network only routes: - name: orders-api paths: ["/"] protocols: ["https"] # the protected API is never served over plain HTTP strip_path: false preserve_host: true plugins: - name: mt-enforcer config: enforcer_url: http://127.0.0.1:9191/authz timeout: 1000

Run with Docker Compose

services/enforcer/integrations/kong/docker-compose.yaml
# Kong Gateway (DB-less) + mt-enforcer. # docker compose run --rm enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer # docker compose up -d # The proxy listener requires a client certificate issued by the projected bundle # (injected NGINX directives); the plugin then asks the enforcer per request. services: enforcer: build: { context: ../.., dockerfile: Dockerfile } image: mt-enforcer:1.0.0 command: ["forward-auth", "--format", "kong", "--state", "/var/lib/mt-enforcer", "--listen", "127.0.0.1:9191", "--admin", "0.0.0.0:9464", "--bundle-out", "/var/run/mt/bundle/client-ca.pem"] ports: ["8443:8443", "9464:9464"] volumes: ["mt-state:/var/lib/mt-enforcer", "mt-bundle:/var/run/mt/bundle"] healthcheck: { test: ["CMD", "/mt-enforcer", "healthcheck"], interval: 5s, retries: 30 } kong: image: kong:3.9.4@sha256:ad6cc0c8cca5b66b3cdab3bde7bc7ca973d4d820dd2c6651a7e3042f5428a76a network_mode: "service:enforcer" depends_on: { enforcer: { condition: service_healthy } } environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /kong/kong.yml KONG_PLUGINS: bundled,mt-enforcer KONG_LUA_PACKAGE_PATH: /kong/plugins-root/?.lua;; KONG_PROXY_LISTEN: 0.0.0.0:8443 ssl http2 KONG_SSL_CERT: /kong/certs/server.pem KONG_SSL_CERT_KEY: /kong/certs/server-key.pem KONG_NGINX_PROXY_SSL_VERIFY_CLIENT: "on" KONG_NGINX_PROXY_SSL_CLIENT_CERTIFICATE: /etc/mt/client-ca.pem KONG_NGINX_PROXY_SSL_VERIFY_DEPTH: "2" KONG_ADMIN_LISTEN: 127.0.0.1:8001 volumes: - ./kong.yml:/kong/kong.yml:ro - ./plugins/mt-enforcer:/kong/plugins-root/kong/plugins/mt-enforcer:ro - ./certs:/kong/certs:ro - mt-bundle:/etc/mt:ro origin: image: ${ORIGIN_IMAGE:-ghcr.io/your-org/orders-api:latest} volumes: { mt-state: {}, mt-bundle: {} }

KONG_NGINX_PROXY_SSL_VERIFY_CLIENT and KONG_NGINX_PROXY_SSL_CLIENT_CERTIFICATE inject ssl_verify_client and ssl_client_certificate into the proxy server block, so the handshake requires a certificate from the bundle and $ssl_client_escaped_cert is available to the plugin.

Contract

Plugin contract
Enforcer answerKong
200 with x-mt-principalSets the identity headers upstream (inbound x-mt-* removed first) and continues.
401 or 403Returns the enforcer status, JSON problem, x-mt-reason and x-mt-request-id.
unreachable or malformedReturns 503 (fail closed).

Tested

The plugin's Lua is run unmodified by services/enforcer/test/real-gateways.sh on OpenResty 1.31 (the runtime Kong Gateway is built on), with the Kong PDK functions it calls, against a real enforcer: allowed and restricted routes, spoofed headers, no client certificate, encoded dot segments and revocation.