From a150c84328fed893a922896163ae3411aad70ef1 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 8 Jun 2026 04:09:35 -0400 Subject: [PATCH 01/18] feat: add CPEX plugin to AuthBridge Integrate the CPEX policy framework as an authbridge plugin (authlib/plugins/cpex). The plugin is a generic bridge: it maps AuthBridge requests to CMF and routes inbound/outbound hooks through CPEX policy chains expressed in the APL DSL plus named CPEX plugins, over the CPEX FFI. Adds the authbridge-cpex proxy-sidecar build that links the pinned CPEX release. Includes an end-to-end hr-cpex demo (HR MCP server, Keycloak realm, agent, and allow/deny/redact scenarios) whose policy exercises the named CPEX plugins: Cedar authorization, PII scanning/redaction, JWT identity, and OAuth token exchange (RFC 8693). Documents the AuthBridge hook system and the CPEX integration. Co-Authored-By: Teryl Taylor Signed-off-by: Frederico Araujo --- .github/workflows/build.yaml | 29 + .gitignore | 8 + CLAUDE.md | 1 + authbridge/CLAUDE.md | 12 + authbridge/authlib/contracts/identity.go | 43 + authbridge/authlib/go.mod | 5 +- authbridge/authlib/go.sum | 6 + authbridge/authlib/pipeline/context.go | 10 + authbridge/authlib/pipeline/extensions.go | 18 + authbridge/authlib/plugins/cpex/cmf_a2a.go | 211 +++ .../authlib/plugins/cpex/cmf_a2a_test.go | 127 ++ authbridge/authlib/plugins/cpex/cmf_body.go | 571 +++++++ .../authlib/plugins/cpex/cmf_body_test.go | 575 +++++++ .../authlib/plugins/cpex/cmf_inference.go | 202 +++ .../plugins/cpex/cmf_inference_test.go | 186 +++ authbridge/authlib/plugins/cpex/config.go | 194 +++ authbridge/authlib/plugins/cpex/headers.go | 122 ++ .../authlib/plugins/cpex/headers_test.go | 76 + authbridge/authlib/plugins/cpex/hooks.go | 30 + authbridge/authlib/plugins/cpex/manager.go | 167 ++ .../authlib/plugins/cpex/manager_cpex.go | 851 ++++++++++ .../authlib/plugins/cpex/manager_stub.go | 25 + .../authlib/plugins/cpex/manager_test.go | 122 ++ authbridge/authlib/plugins/cpex/plugin.go | 457 ++++++ .../authlib/plugins/cpex/plugin_test.go | 669 ++++++++ .../authlib/plugins/jwtvalidation/identity.go | 66 +- .../plugins/jwtvalidation/identity_test.go | 69 + .../plugins/tokenexchange/delegation_test.go | 77 + .../authlib/plugins/tokenexchange/plugin.go | 45 + .../cmd/authbridge-cpex/CPEX_FFI_VERSION | 1 + authbridge/cmd/authbridge-cpex/Dockerfile | 111 ++ authbridge/cmd/authbridge-cpex/entrypoint.sh | 32 + authbridge/cmd/authbridge-cpex/go.mod | 35 + authbridge/cmd/authbridge-cpex/go.sum | 94 ++ authbridge/cmd/authbridge-cpex/main.go | 360 ++++ authbridge/demos/README.md | 18 + authbridge/demos/hr-cpex/Makefile | 114 ++ authbridge/demos/hr-cpex/README.md | 325 ++++ .../demos/hr-cpex/agent/CHAT-WALKTHROUGH.md | 285 ++++ authbridge/demos/hr-cpex/agent/chat.py | 628 +++++++ .../demos/hr-cpex/agent/requirements.txt | 18 + .../demos/hr-cpex/hr-mcp-server/Dockerfile | 12 + .../hr-cpex/hr-mcp-server/requirements.txt | 2 + .../demos/hr-cpex/hr-mcp-server/server.py | 282 ++++ .../demos/hr-cpex/k8s/00-namespace.yaml | 8 + authbridge/demos/hr-cpex/k8s/10-keycloak.yaml | 114 ++ authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml | 62 + .../demos/hr-cpex/k8s/30-authbridge-cpex.yaml | 148 ++ authbridge/demos/hr-cpex/k8s/cpex-policy.yaml | 361 ++++ .../demos/hr-cpex/k8s/realm-export.json | 372 +++++ authbridge/demos/hr-cpex/mint-token.sh | 65 + .../demos/hr-cpex/scenarios/01-bob-allow.sh | 24 + .../demos/hr-cpex/scenarios/02-alice-deny.sh | 23 + .../demos/hr-cpex/scenarios/03-eve-redact.sh | 22 + .../scenarios/04-alice-internal-allow.sh | 41 + .../scenarios/05-alice-external-cedar-deny.sh | 40 + .../hr-cpex/scenarios/06-bob-apl-deny.sh | 41 + .../hr-cpex/scenarios/07-bob-pii-deny.sh | 51 + .../hr-cpex/scenarios/08-bob-taint-deny.sh | 50 + authbridge/demos/hr-cpex/scenarios/_lib.sh | 114 ++ .../demos/hr-cpex/verify-token-exchange.sh | 110 ++ authbridge/docs/cpex-plugin.md | 328 ++++ authbridge/go.work | 3 +- docs/proposals/authbridge-hooks.md | 1459 +++++++++++++++++ 64 files changed, 10724 insertions(+), 3 deletions(-) create mode 100644 authbridge/authlib/contracts/identity.go create mode 100644 authbridge/authlib/plugins/cpex/cmf_a2a.go create mode 100644 authbridge/authlib/plugins/cpex/cmf_a2a_test.go create mode 100644 authbridge/authlib/plugins/cpex/cmf_body.go create mode 100644 authbridge/authlib/plugins/cpex/cmf_body_test.go create mode 100644 authbridge/authlib/plugins/cpex/cmf_inference.go create mode 100644 authbridge/authlib/plugins/cpex/cmf_inference_test.go create mode 100644 authbridge/authlib/plugins/cpex/config.go create mode 100644 authbridge/authlib/plugins/cpex/headers.go create mode 100644 authbridge/authlib/plugins/cpex/headers_test.go create mode 100644 authbridge/authlib/plugins/cpex/hooks.go create mode 100644 authbridge/authlib/plugins/cpex/manager.go create mode 100644 authbridge/authlib/plugins/cpex/manager_cpex.go create mode 100644 authbridge/authlib/plugins/cpex/manager_stub.go create mode 100644 authbridge/authlib/plugins/cpex/manager_test.go create mode 100644 authbridge/authlib/plugins/cpex/plugin.go create mode 100644 authbridge/authlib/plugins/cpex/plugin_test.go create mode 100644 authbridge/authlib/plugins/jwtvalidation/identity_test.go create mode 100644 authbridge/authlib/plugins/tokenexchange/delegation_test.go create mode 100644 authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION create mode 100644 authbridge/cmd/authbridge-cpex/Dockerfile create mode 100644 authbridge/cmd/authbridge-cpex/entrypoint.sh create mode 100644 authbridge/cmd/authbridge-cpex/go.mod create mode 100644 authbridge/cmd/authbridge-cpex/go.sum create mode 100644 authbridge/cmd/authbridge-cpex/main.go create mode 100644 authbridge/demos/hr-cpex/Makefile create mode 100644 authbridge/demos/hr-cpex/README.md create mode 100644 authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md create mode 100755 authbridge/demos/hr-cpex/agent/chat.py create mode 100644 authbridge/demos/hr-cpex/agent/requirements.txt create mode 100644 authbridge/demos/hr-cpex/hr-mcp-server/Dockerfile create mode 100644 authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt create mode 100644 authbridge/demos/hr-cpex/hr-mcp-server/server.py create mode 100644 authbridge/demos/hr-cpex/k8s/00-namespace.yaml create mode 100644 authbridge/demos/hr-cpex/k8s/10-keycloak.yaml create mode 100644 authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml create mode 100644 authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml create mode 100644 authbridge/demos/hr-cpex/k8s/cpex-policy.yaml create mode 100644 authbridge/demos/hr-cpex/k8s/realm-export.json create mode 100755 authbridge/demos/hr-cpex/mint-token.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/01-bob-allow.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/03-eve-redact.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh create mode 100755 authbridge/demos/hr-cpex/scenarios/_lib.sh create mode 100755 authbridge/demos/hr-cpex/verify-token-exchange.sh create mode 100644 authbridge/docs/cpex-plugin.md create mode 100644 docs/proposals/authbridge-hooks.md diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6503fc7bf..eb6c560ef 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -53,6 +53,16 @@ jobs: context: ./authbridge dockerfile: cmd/authbridge-lite/Dockerfile + # AuthBridge proxy-sidecar CPEX image — authbridge-proxy built + # with -tags cpex (links libcpex_ffi.a from a pinned CPEX + # release) so the `cpex` plugin routes hooks through the CPEX + # framework (APL DSL + named CPEX policy plugins). Needs the + # CPEX_FFI_VERSION build-arg, read from the CPEX_FFI_VERSION + # file in the build step below. + - name: authbridge-cpex + context: ./authbridge + dockerfile: cmd/authbridge-cpex/Dockerfile + # SPARC reflection service — the backend the `sparc` plugin calls. # Deployed once per cluster via authbridge/sparc-service/deploy. - name: sparc-service @@ -106,6 +116,24 @@ jobs: # Add 'latest' tag for version tags, workflow_dispatch, and pushes to main type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} + # 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION, + # the single source of truth for the FFI ABI this image links + # (read from the file next to its Dockerfile). Other images leave + # this empty (an undeclared build-arg is ignored by their builds). + - name: Resolve build args + id: buildargs + run: | + if [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then + VERSION="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION)" + { + echo "args<> "$GITHUB_OUTPUT" + else + echo "args=" >> "$GITHUB_OUTPUT" + fi + # 7. Build and push image - name: Build and push ${{ matrix.image_config.name }} uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 @@ -116,3 +144,4 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ steps.buildargs.outputs.args }} diff --git a/.gitignore b/.gitignore index 1d7ebf8a8..7ae84af9d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,11 @@ __pycache__/ .mypy_cache/ test-jwt-rotation.sh kagenti-webhook/bin/ + +# Go build artifacts — local `go build` outputs in cmd/* dirs +# (binary name matches the package dir; trivially `git add`-ed by mistake). +authbridge/cmd/abctl/abctl +authbridge/cmd/authbridge-proxy/authbridge-proxy +authbridge/cmd/authbridge-envoy/authbridge-envoy +authbridge/cmd/authbridge-lite/authbridge-lite +authbridge/cmd/authbridge-cpex/authbridge-cpex diff --git a/CLAUDE.md b/CLAUDE.md index e7e1ad726..424ea7ce0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,6 +167,7 @@ All images are pushed to `ghcr.io/kagenti/kagenti-extensions/` from | **`authbridge`** | **`authbridge/cmd/authbridge-proxy/Dockerfile`** | **proxy-sidecar combined image (default mode): authbridge-proxy (full plugin set incl. parsers) + spiffe-helper. No Envoy.** | | `authbridge-envoy` | `authbridge/cmd/authbridge-envoy/Dockerfile` | envoy-sidecar combined image: Envoy + authbridge-envoy (ext_proc, full plugin set) + spiffe-helper | | `authbridge-lite` | `authbridge/cmd/authbridge-lite/Dockerfile` | proxy-sidecar lite combined image: authbridge-lite (auth gates only, parsers dropped) + spiffe-helper. Same listener layout as `authbridge`; not yet referenced by the operator's default config | +| `authbridge-cpex` | `authbridge/cmd/authbridge-cpex/Dockerfile` | proxy-sidecar build with the CPEX plugin: authbridge-proxy built with `-tags cpex`, links `libcpex_ffi.a` from a pinned CPEX release (CGO_ENABLED=1). Routes hooks through the CPEX framework (APL DSL + named CPEX policy plugins). FFI ABI version is read from `authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION` | | `proxy-init` | `authbridge/proxy-init/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar mode only) | In all three combined images, `spiffe-helper` is started conditionally diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index f2ac14730..fc822a045 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -17,6 +17,12 @@ binaries with shared auth logic in `authlib/`: - `cmd/authbridge-lite/` — proxy-sidecar mode, lite plugin set (auth gates only, parsers dropped). For size-optimized deployments that don't need protocol-aware session events. +- `cmd/authbridge-cpex/` — proxy-sidecar mode, full plugin set plus the + `cpex` plugin. Built with `-tags cpex` and requires cgo (CGO_ENABLED=1): + it links `libcpex_ffi.a` from a pinned CPEX release to route hooks through + the CPEX framework (APL DSL + named CPEX policy plugins). The FFI ABI + version lives in `cmd/authbridge-cpex/CPEX_FFI_VERSION`. The other three + binaries are pure-Go (CGO_ENABLED=0) and do not import the cpex package. Each binary is hardcoded to its deployment shape; mode is no longer selected at runtime. The YAML `mode:` field must match the binary or boot fails. @@ -62,6 +68,12 @@ authbridge/ │ ├── Dockerfile # proxy-sidecar lite combined image │ └── entrypoint.sh │ +├── cmd/authbridge-cpex/ # proxy-sidecar mode + cpex plugin. -tags cpex, cgo required. +│ ├── main.go +│ ├── Dockerfile # proxy-sidecar build linking libcpex_ffi.a +│ ├── CPEX_FFI_VERSION # pinned CPEX FFI ABI version (build-arg source of truth) +│ └── entrypoint.sh +│ ├── proxy-init/ # iptables init container (envoy-sidecar mode only) │ ├── init-iptables.sh # iptables setup script │ ├── Dockerfile.init # proxy-init container image diff --git a/authbridge/authlib/contracts/identity.go b/authbridge/authlib/contracts/identity.go new file mode 100644 index 000000000..0c8430b60 --- /dev/null +++ b/authbridge/authlib/contracts/identity.go @@ -0,0 +1,43 @@ +package contracts + +// ClaimsCarrier is an optional capability interface a pipeline.Identity +// may implement to expose richer claim data than the minimal +// pipeline.Identity surface (Subject / ClientID / Scopes). +// +// It exists so consumers that want issuer / audience / auth-method / +// curated claims — chiefly the cpex plugin building a policy-input +// document — can read them without growing pipeline.Identity (which +// stays deliberately minimal so any auth shape can satisfy it). Mirrors +// the ContentSource pattern: consumers type-assert to this interface and +// simply skip the enrichment when the concrete identity doesn't +// implement it. +// +// if cc, ok := pctx.Identity.(contracts.ClaimsCarrier); ok { +// authMethod = cc.AuthMethod() +// claims = cc.Claims() +// } +// +// Producers (e.g. jwt-validation's claims adapter) curate what Claims +// returns — a small, safe-to-forward set (issuer, audience, expiry), NOT +// the full raw claim map. The session API and CPEX traces both surface +// this data, so producers must keep it free of secrets. +type ClaimsCarrier interface { + // Issuer is the token issuer (`iss`), or "" when not applicable. + Issuer() string + + // Audience is the token audience list (`aud`), or nil. + Audience() []string + + // AuthMethod names how the caller authenticated — "jwt", "mtls", + // "spiffe", etc. Drives policies that branch on authentication + // strength. "" when the producer can't classify it. + AuthMethod() string + + // Claims returns a curated, string-valued claim set safe to forward + // into policy context and observability surfaces. Producers pick the + // keys (conventionally "issuer", "audience", "exp"); they MUST NOT + // dump the full raw claim map, which may contain secrets or PII. + // Values are strings so the set maps cleanly onto CPEX's + // SubjectExtension.Claims (map[string]string) without lossy coercion. + Claims() map[string]string +} diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index dd1627835..301ed081b 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -1,8 +1,9 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib -go 1.25.0 +go 1.25.4 require ( + github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 github.com/envoyproxy/go-control-plane/envoy v1.37.0 github.com/fsnotify/fsnotify v1.10.1 github.com/gobwas/glob v0.2.3 @@ -57,6 +58,8 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/tchap/go-patricia/v2 v2.3.2 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 0887457ec..bba5a1751 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -30,6 +30,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 h1:oKlTk1+q00z0/YdnZUepelzqKa3Xc1wB+o6//L4DsHM= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -153,6 +155,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM= github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 014dd9e29..d7ca535bc 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -277,6 +277,16 @@ func (c *Context) clearCurrent() { // them. func (c *Context) RejectingPlugin() string { return c.rejectingPlugin } +// CurrentPhase reports the phase of the plugin dispatch currently in +// flight — InvocationPhaseRequest while Pipeline.Run is iterating +// OnRequest, InvocationPhaseResponse while Pipeline.RunResponse is +// iterating OnResponse, and "" outside a dispatch. Plugins read it to +// distinguish request from response without inferring the phase from +// body presence (an empty-bodied 204 response would otherwise look +// like a request). Set by the framework via setCurrent before each +// dispatch; see SetCurrentPlugin. +func (c *Context) CurrentPhase() InvocationPhase { return c.currentPhase } + // setRejectingPlugin records the name of the plugin that returned // Reject. Framework-internal; callers in Pipeline.Run / RunResponse // set this once per request, never overwrite (first rejection wins, diff --git a/authbridge/authlib/pipeline/extensions.go b/authbridge/authlib/pipeline/extensions.go index d3824bf8e..221c056ad 100644 --- a/authbridge/authlib/pipeline/extensions.go +++ b/authbridge/authlib/pipeline/extensions.go @@ -391,10 +391,28 @@ func (d *DelegationExtension) Depth() int { } // DelegationHop represents one hop in the delegation chain. +// +// Audience, Strategy, and FromCache enrich a hop produced by an +// AuthBridge auth plugin (today: token-exchange) so downstream policy +// can reason about WHAT a token was minted for and HOW. They map onto +// the corresponding CPEX DelegationHop fields. All three are optional — +// a producer that only knows the subject leaves them zero. type DelegationHop struct { SubjectID string Scopes []string Timestamp time.Time + + // Audience is the target audience the hop's token was minted for + // (RFC 8693 `audience`). Empty when the hop isn't an exchange. + Audience string + + // Strategy names how the hop was produced — e.g. "token-exchange" + // for an RFC 8693 exchange. Empty when unclassified. + Strategy string + + // FromCache reports the hop's token was served from the exchange + // cache rather than freshly minted at the IdP. + FromCache bool } // AppendHop adds a hop to the delegation chain. This is the only way to extend diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a.go b/authbridge/authlib/plugins/cpex/cmf_a2a.go new file mode 100644 index 000000000..833fe2359 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_a2a.go @@ -0,0 +1,211 @@ +package cpex + +import ( + "encoding/json" + "fmt" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// A2A (JSON-RPC) body write-back. Like the inference re-serializers, the +// cgo adapter extracts redacted text from the CPEX-modified CMF Message +// and these functions splice it back into the ORIGINAL A2A envelope so +// unrelated fields survive. Only kind=="text" message/artifact parts +// participate — matching a2aToCMFParts on the read side — so positional +// alignment is exact and structured data/file parts are never corrupted. +// Tag-free for CGO_ENABLED=0 unit tests. + +// a2aResponseParts extracts the artifact text parts from a non-streaming +// A2A JSON-RPC response body. Used on the response phase, where cpex runs +// before a2a-parser and so must parse the body itself rather than read the +// not-yet-populated a2a.Artifact (mirrors inferenceResponseParts / +// extractToolResultFromBody). +// +// Emits one assistant text part per text-kind artifact part. Streaming +// (SSE) bodies don't parse as one JSON object and yield nil — consistent +// with the response write-back failing closed on SSE. +func a2aResponseParts(body []byte) []cmfPart { + if len(body) == 0 { + return nil + } + var envelope map[string]any + if json.Unmarshal(body, &envelope) != nil { + return nil + } + result, ok := envelope["result"].(map[string]any) + if !ok { + return nil + } + artifacts, ok := result["artifacts"].([]any) + if !ok { + return nil + } + var parts []cmfPart + for _, a := range artifacts { + artifact, ok := a.(map[string]any) + if !ok { + continue + } + ps, ok := artifact["parts"].([]any) + if !ok { + continue + } + for _, p := range ps { + po, ok := p.(map[string]any) + if !ok { + continue + } + if kind, _ := po["kind"].(string); kind != "text" { + continue + } + if t, ok := po["text"].(string); ok && t != "" { + parts = append(parts, cmfPart{Kind: cmfPartText, Text: t}) + } + } + } + return parts +} + +// applyA2ARequestBodyMod rewrites pctx.Body — an A2A JSON-RPC request — +// replacing `params.message.parts[].text` for each text-kind part, +// positionally from newTexts (the ordered redacted text parts CPEX +// returned, which the read path produced one-per-text-part in order). +// +// Alignment is enforced: the count of text-kind parts in the body MUST +// equal len(newTexts); a mismatch returns an error so the caller fails +// closed rather than mapping a redaction onto the wrong part. Non-text +// parts (data/file) are skipped on both sides and never counted. +// +// Returns mutated=false (no error) when the body has no +// params.message.parts to rewrite; mutated=true only when a value +// actually changed. +func applyA2ARequestBodyMod(pctx *pipeline.Context, newTexts []string) (mutated bool, err error) { + if len(pctx.Body) == 0 { + return false, nil + } + var envelope map[string]any + if err := json.Unmarshal(pctx.Body, &envelope); err != nil { + return false, fmt.Errorf("decode A2A request body as JSON: %w", err) + } + parts, ok := a2aMessageParts(envelope) + if !ok { + // No params.message.parts — nothing to rewrite (e.g. a + // non-message method). Not an error. + return false, nil + } + + // Gather the text-kind parts in order so the count check and the + // assignment loop see the same set. + targets := make([]map[string]any, 0, len(parts)) + for _, p := range parts { + po, ok := p.(map[string]any) + if !ok { + continue + } + if kind, _ := po["kind"].(string); kind == "text" { + targets = append(targets, po) + } + } + + if len(targets) != len(newTexts) { + return false, fmt.Errorf( + "A2A request part count drift: body has %d text parts, CPEX returned %d text parts", + len(targets), len(newTexts)) + } + + changed := false + for i, po := range targets { + if po["text"] != newTexts[i] { + po["text"] = newTexts[i] + changed = true + } + } + if !changed { + return false, nil + } + + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize A2A request body: %w", err) + } + pctx.SetBody(newBody) + return true, nil +} + +// applyA2AResponseBodyMod rewrites pctx.ResponseBody — a non-streaming +// A2A JSON-RPC response — replacing the first artifact text part with +// newArtifact (the single redacted text part CPEX returned on the +// response phase). +// +// A2A responses are frequently SSE streams (message/stream); those won't +// parse as one JSON object, so a requested redaction can't be applied and +// we return an error to fail closed rather than forward unredacted output. +// For the non-streaming JSON-RPC shape it rewrites the first text part +// under result.artifacts[].parts[]. +// +// Returns mutated=false (no error) when newArtifact is empty or there's no +// artifact text part to replace. +func applyA2AResponseBodyMod(pctx *pipeline.Context, newArtifact string) (mutated bool, err error) { + if len(pctx.ResponseBody) == 0 || newArtifact == "" { + return false, nil + } + var envelope map[string]any + if err := json.Unmarshal(pctx.ResponseBody, &envelope); err != nil { + return false, fmt.Errorf("decode A2A response body as JSON (streaming responses are not rewritable): %w", err) + } + result, ok := envelope["result"].(map[string]any) + if !ok { + return false, nil + } + artifacts, ok := result["artifacts"].([]any) + if !ok { + return false, nil + } + + for _, a := range artifacts { + artifact, ok := a.(map[string]any) + if !ok { + continue + } + parts, ok := artifact["parts"].([]any) + if !ok { + continue + } + for _, p := range parts { + po, ok := p.(map[string]any) + if !ok { + continue + } + if kind, _ := po["kind"].(string); kind != "text" { + continue + } + if _, ok := po["text"].(string); !ok { + continue + } + po["text"] = newArtifact + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize A2A response body: %w", err) + } + pctx.SetResponseBody(newBody) + return true, nil + } + } + return false, nil +} + +// a2aMessageParts navigates a decoded A2A JSON-RPC request envelope to +// params.message.parts, returning the parts slice and ok=false when the +// path is absent or the wrong shape. +func a2aMessageParts(envelope map[string]any) ([]any, bool) { + params, ok := envelope["params"].(map[string]any) + if !ok { + return nil, false + } + message, ok := params["message"].(map[string]any) + if !ok { + return nil, false + } + parts, ok := message["parts"].([]any) + return parts, ok +} diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go new file mode 100644 index 000000000..44548ad4f --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go @@ -0,0 +1,127 @@ +package cpex + +import ( + "encoding/json" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// --- a2aToCMFParts (request) --- + +func TestA2AToCMFParts_RequestTextPartsOnly(t *testing.T) { + a2a := &pipeline.A2AExtension{ + Method: "message/send", + Role: "user", + Parts: []pipeline.A2APart{ + {Kind: "text", Content: "hello"}, + {Kind: "data", Content: `{"x":1}`}, // excluded — structured data + {Kind: "text", Content: "world"}, + {Kind: "file", Content: "file:///x"}, // excluded + }, + } + parts := a2aToCMFParts(a2a, false, nil) + if len(parts) != 2 { + t.Fatalf("want 2 text parts (data/file excluded), got %d: %+v", len(parts), parts) + } + if parts[0].Text != "hello" || parts[1].Text != "world" { + t.Fatalf("unexpected parts: %+v", parts) + } +} + +// --- a2aResponseParts (response phase parses body) --- + +func TestA2AResponseParts_ArtifactText(t *testing.T) { + body := []byte(`{"result":{"taskId":"t1","artifacts":[{"parts":[{"kind":"text","text":"final answer"},{"kind":"data","data":{"k":1}}]}]}}`) + parts := a2aResponseParts(body) + if len(parts) != 1 { + t.Fatalf("want 1 artifact text part, got %d: %+v", len(parts), parts) + } + if parts[0].Kind != cmfPartText || parts[0].Text != "final answer" { + t.Fatalf("part = %+v", parts[0]) + } +} + +func TestA2AResponseParts_StreamingYieldsNil(t *testing.T) { + sse := []byte("data: {\"result\":{\"kind\":\"status-update\"}}\n\n") + if parts := a2aResponseParts(sse); parts != nil { + t.Fatalf("streaming should yield nil, got %+v", parts) + } +} + +// --- applyA2ARequestBodyMod --- + +func TestA2ARequestBodyMod_RedactsTextParts(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ssn 123-45-6789"},{"kind":"data","data":{"k":1}}]}}}`), + } + mutated, err := applyA2ARequestBodyMod(pctx, []string{"ssn [REDACTED]"}) + if err != nil || !mutated { + t.Fatalf("mutated=%v err=%v", mutated, err) + } + var decoded map[string]any + if err := json.Unmarshal(pctx.Body, &decoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + parts := decoded["params"].(map[string]any)["message"].(map[string]any)["parts"].([]any) + if got := parts[0].(map[string]any)["text"]; got != "ssn [REDACTED]" { + t.Fatalf("text part not redacted: %v", got) + } + // Data part survives untouched. + if _, ok := parts[1].(map[string]any)["data"]; !ok { + t.Fatalf("data part corrupted: %v", parts[1]) + } +} + +func TestA2ARequestBodyMod_CountDriftFailsClosed(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"params":{"message":{"parts":[{"kind":"text","text":"a"},{"kind":"text","text":"b"}]}}}`), + } + mutated, err := applyA2ARequestBodyMod(pctx, []string{"only-one"}) + if err == nil || mutated { + t.Fatalf("expected drift fail-closed; mutated=%v err=%v", mutated, err) + } +} + +// --- applyA2AResponseBodyMod --- + +func TestA2AResponseBodyMod_RewritesArtifact(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"leaked 123-45-6789"}]}]}}`), + } + mutated, err := applyA2AResponseBodyMod(pctx, "leaked [REDACTED]") + if err != nil || !mutated { + t.Fatalf("mutated=%v err=%v", mutated, err) + } + if want := "leaked [REDACTED]"; !jsonContains(t, pctx.ResponseBody, want) { + t.Fatalf("artifact not redacted in %s", pctx.ResponseBody) + } +} + +func TestA2AResponseBodyMod_StreamingFailsClosed(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte("data: {\"result\":{}}\n\n"), + } + mutated, err := applyA2AResponseBodyMod(pctx, "x") + if err == nil || mutated { + t.Fatalf("streaming response should fail closed; mutated=%v err=%v", mutated, err) + } +} + +func jsonContains(t *testing.T, body []byte, substr string) bool { + t.Helper() + var v any + if err := json.Unmarshal(body, &v); err != nil { + t.Fatalf("body not valid JSON: %v", err) + } + return contains(string(body), substr) +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/authbridge/authlib/plugins/cpex/cmf_body.go b/authbridge/authlib/plugins/cpex/cmf_body.go new file mode 100644 index 000000000..4275ee991 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_body.go @@ -0,0 +1,571 @@ +package cpex + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// sessionIDFromHeaders returns the X-Session-Id request header, the +// session-correlation key AuthBridge threads into CPEX's session resolver +// (tier-0 Agent.SessionID) for non-A2A traffic. MCP and inference requests +// carry no native session id, so an upstream supplies one here to let +// session-scoped CPEX state — e.g. taint labels — persist across the +// separate request/response cycles of one logical session. Returns "" when +// absent. Tag-free so it's unit-tested without cgo (buildCMF, which uses +// it, is cgo-gated). +func sessionIDFromHeaders(h http.Header) string { + return h.Get("X-Session-Id") +} + +// cmfPartKind enumerates the structured CMF content part buildCMF emits +// for a given MCP message. Kept protocol-neutral (no rcpex import) so the +// MCP→CMF decision is unit-testable without cgo/FFI; the cgo adapter +// (manager_cpex.go) converts a cmfPart into the matching rcpex ContentPart. +type cmfPartKind int + +const ( + // cmfPartText is the opaque text fallback. Used for protocol + // mechanics (tools/list, initialize, …) and when no MCP extension + // was parsed — CPEX gets the raw body as a single text part, no + // per-tool route matches, and the request flows unchanged. This + // preserves pre-structured-mapping behavior for non-action traffic. + cmfPartText cmfPartKind = iota + cmfPartToolCall + cmfPartPromptRequest + cmfPartResourceRef + cmfPartToolResult +) + +// cmfPart is a protocol-neutral description of one CMF content part. The +// cgo adapter maps it onto an rcpex constructor (NewToolCallPart, …). Only +// the fields relevant to Kind are populated. +type cmfPart struct { + Kind cmfPartKind + + // Name is the tool/prompt name. It is the routing key CPEX matches + // against per-tool routes (`- tool: get_compensation`), so it MUST be + // set for ToolCall / PromptRequest / ToolResult parts. + Name string + + // Arguments is the tool_call / prompt_request argument object that + // APL `args.*` predicates (and Cedar `${args.*}`) evaluate against. + Arguments map[string]any + + // URI is the resources/read target (ResourceRef parts). + URI string + + // CorrelationID threads the JSON-RPC request id through as the + // part's *_id field so request and response parts correlate. + CorrelationID string + + // Content is the tool_result payload (ToolResult parts) — the parsed + // inner object, not the MCP result envelope. + Content any + + // IsError flags a tool_result built from a JSON-RPC error response. + IsError bool + + // Text is the opaque-fallback body (cmfPartText only). + Text string +} + +// mcpToCMFPart decides the structured CMF content part for an MCP message. +// `isResponse` selects the phase; requestBody / responseBody supply the +// payload (and the opaque text fallback) for that phase. +// +// On the response phase the tool result is parsed from responseBody, NOT +// from mcp.Result: response hooks run in reverse pipeline order, so the +// cpex plugin executes BEFORE mcp-parser and mcp.Result isn't populated +// yet. The tool name still persists in mcp.Params from the request phase. +func mcpToCMFPart(mcp *pipeline.MCPExtension, isResponse bool, requestBody, responseBody []byte) cmfPart { + if mcp == nil { + body := requestBody + if isResponse { + body = responseBody + } + return cmfPart{Kind: cmfPartText, Text: string(body)} + } + corrID := stringifyRPCID(mcp.RPCID) + + if isResponse { + // Response phase. Only tools/call yields a structured result + // today; other methods fall back to opaque text. + if mcp.Method == "tools/call" { + return cmfPart{ + Kind: cmfPartToolResult, + Name: mcpParamName(mcp.Params), + CorrelationID: corrID, + Content: extractToolResultFromBody(responseBody), + IsError: mcp.Err != nil, + } + } + return cmfPart{Kind: cmfPartText, Text: string(responseBody)} + } + + // Request phase. + switch mcp.Method { + case "tools/call": + return cmfPart{ + Kind: cmfPartToolCall, + Name: mcpParamName(mcp.Params), + Arguments: mcpParamArgs(mcp.Params), + CorrelationID: corrID, + } + case "prompts/get": + return cmfPart{ + Kind: cmfPartPromptRequest, + Name: mcpParamName(mcp.Params), + Arguments: mcpParamArgs(mcp.Params), + CorrelationID: corrID, + } + case "resources/read": + return cmfPart{ + Kind: cmfPartResourceRef, + URI: mcpParamURI(mcp.Params), + CorrelationID: corrID, + } + default: + // Protocol mechanics (tools/list, initialize, …) — no per-tool + // route to match. Pass the raw body through as text. + return cmfPart{Kind: cmfPartText, Text: string(requestBody)} + } +} + +// cmfEntity maps a cmfPart to the (entity_type, entity_name) pair CPEX's +// route resolver keys on. cpex-core only dispatches a route's APL policy +// handlers (require / cedar / delegate / field-redaction) when +// Extensions.meta carries BOTH entity_type and entity_name (see +// crates/cpex-core/src/manager.rs filter_entries_by_route); without them +// the per-tool gates never fire and only always-on global plugins run. +// caller leaves meta unset so non-action traffic isn't force-routed. +func cmfEntity(part cmfPart) (entityType, entityName string) { + switch part.Kind { + case cmfPartToolCall, cmfPartToolResult: + return "tool", part.Name + case cmfPartPromptRequest: + return "prompt", part.Name + case cmfPartResourceRef: + return "resource", part.URI + default: + return "", "" + } +} + +// pctxToCMFParts is the top-level protocol selector: it turns whichever +// protocol extension is populated into ordered CMF content parts plus the +// (entity_type, entity_name) routing coordinates. Precedence MCP → +// inference → A2A mirrors the priority order parsers populate in, and any +// unpopulated case falls back to a single opaque-text part carrying the +// raw body — preserving pre-structured-mapping behavior for traffic no +// parser claimed (control-plane RPCs, unknown formats). +// +// Splitting this from the cgo adapter keeps the MCP/inference/A2A → CMF +// decision unit-testable under CGO_ENABLED=0; manager_cpex.go converts the +// returned cmfParts into rcpex ContentParts and stamps the entity coords +// onto Extensions.Meta. +func pctxToCMFParts(pctx *pipeline.Context, isResponse bool) (parts []cmfPart, entityType, entityName string) { + switch { + case pctx.Extensions.MCP != nil: + part := mcpToCMFPart(pctx.Extensions.MCP, isResponse, pctx.Body, pctx.ResponseBody) + et, en := cmfEntity(part) + return []cmfPart{part}, et, en + + case pctx.Extensions.Inference != nil: + // Inference is always an action; route by model so operators can + // scope per-route APL gates with `- model: `. Only structured + // text/tool parts cross — no opaque body part. Model persists from + // the request phase, so entity routing works on responses too. + return inferenceToCMFParts(pctx.Extensions.Inference, isResponse, pctx.ResponseBody), + "model", pctx.Extensions.Inference.Model + + case pctx.Extensions.A2A != nil: + // Route A2A by its JSON-RPC method (message/send, message/stream), + // which persists from the request phase. + return a2aToCMFParts(pctx.Extensions.A2A, isResponse, pctx.ResponseBody), + "a2a_method", pctx.Extensions.A2A.Method + + default: + body := pctx.Body + if isResponse { + body = pctx.ResponseBody + } + return []cmfPart{{Kind: cmfPartText, Text: string(body)}}, "", "" + } +} + +// inferenceToCMFParts maps an InferenceExtension onto ordered CMF content +// parts for the given phase. +// +// Request phase: one text part per request message with non-empty string +// content, in message order (read from the extension, which inference-parser +// populated before cpex on the forward request pass). Flattening the whole +// prompt to text parts makes every message redactable via cmf.llm_input, +// and the strict "non-empty string content, in order" rule is the contract +// applyInferenceRequestBodyMod relies on to map redacted text back onto the +// right body message positionally. +// +// Response phase: parsed from responseBody, NOT from inf.Completion. +// Response hooks run in reverse pipeline order, so cpex executes BEFORE +// inference-parser's OnResponse and inf.Completion isn't populated yet — +// exactly the situation mcpToCMFPart handles for tool results. See +// inferenceResponseParts. +func inferenceToCMFParts(inf *pipeline.InferenceExtension, isResponse bool, responseBody []byte) []cmfPart { + if inf == nil { + return nil + } + if isResponse { + return inferenceResponseParts(responseBody) + } + var parts []cmfPart + for _, m := range inf.Messages { + if m.Content == "" { + continue + } + parts = append(parts, cmfPart{Kind: cmfPartText, Text: m.Content}) + } + return parts +} + +// a2aToCMFParts maps an A2AExtension onto ordered CMF content parts. +// +// Request phase: one text part per text-kind message part with non-empty +// content, in order (from the extension). Only kind=="text" parts +// participate — data/file parts carry JSON blobs / URIs that a text +// redactor would corrupt, so they're excluded from BOTH the read mapping +// here and the write-back in applyA2ARequestBodyMod, keeping positional +// alignment exact. +// +// Response phase: parsed from responseBody (not a2a.Artifact, which +// a2a-parser hasn't populated yet on the reverse-order response pass). See +// a2aResponseParts. +func a2aToCMFParts(a2a *pipeline.A2AExtension, isResponse bool, responseBody []byte) []cmfPart { + if a2a == nil { + return nil + } + if isResponse { + return a2aResponseParts(responseBody) + } + var parts []cmfPart + for _, p := range a2a.Parts { + if p.Kind != "text" || p.Content == "" { + continue + } + parts = append(parts, cmfPart{Kind: cmfPartText, Text: p.Content}) + } + return parts +} + +// parseToolArgs decodes an LLM tool call's raw argument string (which the +// model emits as a JSON object string) into the map APL `args.*` +// predicates evaluate against. Malformed model output is preserved rather +// than dropped: on a parse miss the raw string is wrapped as +// {"_raw": } so a policy can still see (and a redactor still +// reach) the content. Empty input yields nil. +func parseToolArgs(raw string) map[string]any { + if raw == "" { + return nil + } + var args map[string]any + if json.Unmarshal([]byte(raw), &args) == nil { + return args + } + return map[string]any{"_raw": raw} +} + +// inferProvider derives a coarse provider label from a model id for +// LLMExtension.Provider. Best-effort and conservative — it returns "" +// (provider unset) rather than guess for unrecognized model families, so +// a policy keying on provider only ever sees a value we're confident in. +func inferProvider(model string) string { + m := strings.ToLower(model) + switch { + case strings.HasPrefix(m, "gpt-"), strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), + strings.HasPrefix(m, "text-"), strings.Contains(m, "davinci"): + return "openai" + case strings.HasPrefix(m, "claude"): + return "anthropic" + case strings.HasPrefix(m, "gemini"): + return "google" + default: + return "" + } +} + +// inferenceHistory renders the request conversation as role-tagged +// entries for CPEX's AgentExtension.Conversation.History, giving policies +// that need turn/role context (not just flat redactable text) a structured +// view. Tag-free + []map[string]any so it's unit-testable and assigns +// cleanly into the rcpex []any history field. Empty-content messages are +// dropped to match the text-part flattening. Returns nil when empty. +func inferenceHistory(inf *pipeline.InferenceExtension) []map[string]any { + if inf == nil { + return nil + } + var out []map[string]any + for _, m := range inf.Messages { + if m.Content == "" { + continue + } + out = append(out, map[string]any{"role": m.Role, "content": m.Content}) + } + return out +} + +// extractToolResultFromBody parses a JSON-RPC tools/call response body and +// returns the inner tool result payload (via extractToolResultContent on +// the `result` object). Used on the response phase, where the cpex plugin +// runs before mcp-parser and so must parse the body itself rather than +// read the not-yet-populated mcp.Result. +func extractToolResultFromBody(body []byte) any { + if len(body) == 0 { + return nil + } + var envelope map[string]any + if json.Unmarshal(body, &envelope) != nil { + return nil + } + result, _ := envelope["result"].(map[string]any) + return extractToolResultContent(result) +} + +// extractToolResultContent pulls the inner tool payload out of an MCP +// tools/call result envelope so APL `result.*` predicates resolve against +// the real data (e.g. `result.ssn`), not the MCP content wrapper. Prefers +// the typed structuredContent (MCP 2025-06-18); falls back to parsing the +// first text block as JSON; on parse-miss wraps the raw text as +// {"text": }. +func extractToolResultContent(result map[string]any) any { + if result == nil { + return nil + } + if sc, ok := result["structuredContent"]; ok { + return sc + } + content, ok := result["content"].([]any) + if !ok { + return nil + } + for _, b := range content { + block, ok := b.(map[string]any) + if !ok { + continue + } + if t, _ := block["type"].(string); t != "text" { + continue + } + s, ok := block["text"].(string) + if !ok { + continue + } + var inner any + if json.Unmarshal([]byte(s), &inner) == nil { + return inner + } + return map[string]any{"text": s} + } + return nil +} + +// mcpParamName extracts params.name (tool / prompt name) as a string. +func mcpParamName(params map[string]any) string { + if params == nil { + return "" + } + if n, ok := params["name"].(string); ok { + return n + } + return "" +} + +// mcpParamArgs extracts params.arguments as an object, or nil. +func mcpParamArgs(params map[string]any) map[string]any { + if params == nil { + return nil + } + if a, ok := params["arguments"].(map[string]any); ok { + return a + } + return nil +} + +// mcpParamURI extracts params.uri (resources/read target) as a string. +func mcpParamURI(params map[string]any) string { + if params == nil { + return "" + } + if u, ok := params["uri"].(string); ok { + return u + } + return "" +} + +// stringifyRPCID renders a JSON-RPC id (string | number | null) as the +// stable string CPEX uses for the part's correlation id. JSON numbers +// decode to float64; format them without a spurious decimal so id 1 stays +// "1", not "1.000000" or "1e+00". +func stringifyRPCID(id any) string { + switch v := id.(type) { + case nil: + return "" + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case json.Number: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +// MCPRequestBodyMod describes what changes to apply to an MCP JSON-RPC +// request body. The cgo adapter extracts these values from a CPEX- +// modified CMF Message and hands them to applyMCPRequestBodyMod. +// +// Exactly one field is consumed per method: +// +// tools/call → NewArguments → params.arguments +// prompts/get → NewArguments → params.arguments +// resources/read → NewURI → params.uri +// +// Other methods are no-ops — the operator's APL policy can't rewrite +// protocol mechanics (initialize, tools/list, etc.) because there's +// no semantically meaningful target for the rewrite. +type MCPRequestBodyMod struct { + NewArguments map[string]any + NewURI string +} + +// applyMCPRequestBodyMod rewrites pctx.Body, which is expected to be +// an MCP JSON-RPC request. Returns mutated=true when the body was +// changed and SetBody was called; returns mutated=false (with no +// error) when the original body didn't match the expected shape +// (e.g., parsed JSON has no `params` object, method is unsupported, +// or the mod struct's relevant field is empty for the method). +func applyMCPRequestBodyMod(pctx *pipeline.Context, method string, mod MCPRequestBodyMod) (mutated bool, err error) { + if len(pctx.Body) == 0 { + return false, nil + } + var envelope map[string]any + if err := json.Unmarshal(pctx.Body, &envelope); err != nil { + return false, fmt.Errorf("decode request body as JSON: %w", err) + } + params, _ := envelope["params"].(map[string]any) + if params == nil { + return false, nil + } + + switch method { + case "tools/call", "prompts/get": + if len(mod.NewArguments) == 0 { + return false, nil + } + params["arguments"] = mod.NewArguments + case "resources/read": + if mod.NewURI == "" { + return false, nil + } + params["uri"] = mod.NewURI + default: + return false, nil + } + + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize request body: %w", err) + } + pctx.SetBody(newBody) + return true, nil +} + +// applyMCPResponseBodyMod rewrites pctx.ResponseBody for a tools/call +// JSON-RPC response. The new content replaces: +// +// - result.content[].text — the canonical MCP text block, stringified +// as JSON (pretty-printed when possible). +// - result.structuredContent — only when the original response had it. +// Don't introduce structuredContent on a response that didn't carry +// it; clients sniffing for the new shape would be surprised. +// +// Returns mutated=true when SetResponseBody was called; mutated=false +// when the body wasn't a tools/call response, had no result.content, +// or the response had no replaceable text block. +func applyMCPResponseBodyMod(pctx *pipeline.Context, method string, newContent any) (mutated bool, err error) { + if method != "tools/call" { + return false, nil + } + if len(pctx.ResponseBody) == 0 || newContent == nil { + return false, nil + } + var envelope map[string]any + if err := json.Unmarshal(pctx.ResponseBody, &envelope); err != nil { + return false, fmt.Errorf("decode response body as JSON: %w", err) + } + result, ok := envelope["result"].(map[string]any) + if !ok { + return false, nil + } + + replaced := false + + // Primary path: rewrite the first text block in result.content[]. + // This is what every MCP client we see today reads from. + if contentArr, ok := result["content"].([]any); ok { + for _, block := range contentArr { + blockObj, ok := block.(map[string]any) + if !ok { + continue + } + if t, ok := blockObj["type"].(string); ok && t == "text" { + blockObj["text"] = stringifyForTextBlock(newContent) + replaced = true + break + } + } + } + + // Secondary path: update structuredContent when it was already + // present. Operators can opt clients into the newer + // structuredContent shape by populating it in the upstream + // response; CPEX rewrites it consistently. We never introduce + // it on a response that lacked it. + if _, has := result["structuredContent"]; has { + result["structuredContent"] = newContent + replaced = true + } + + if !replaced { + return false, nil + } + + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize response body: %w", err) + } + pctx.SetResponseBody(newBody) + return true, nil +} + +// stringifyForTextBlock turns the modified content value back into the +// string the MCP text block expects. We try pretty-printed JSON first +// (matches the upstream MCP server's typical output, makes diffs +// readable in session events); fall back to compact JSON on error; +// fall back to %v on impossible-to-marshal types. +func stringifyForTextBlock(v any) string { + if s, ok := v.(string); ok { + // Common case: the redactor handed back a string verbatim. + return s + } + if b, err := json.MarshalIndent(v, "", " "); err == nil { + return string(b) + } + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return fmt.Sprintf("%v", v) +} diff --git a/authbridge/authlib/plugins/cpex/cmf_body_test.go b/authbridge/authlib/plugins/cpex/cmf_body_test.go new file mode 100644 index 000000000..5683f803c --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_body_test.go @@ -0,0 +1,575 @@ +package cpex + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// --- applyMCPRequestBodyMod --- + +func TestMCPRequestBodyMod_ToolsCallArgsReplaced(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_compensation","arguments":{"employee_id":"E001","include_ssn":true}}}`), + } + newArgs := map[string]any{"employee_id": "E001"} // include_ssn redacted + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: newArgs}) + if err != nil { + t.Fatalf("err: %v", err) + } + if !mutated { + t.Fatal("expected mutated=true") + } + var decoded map[string]any + if err := json.Unmarshal(pctx.Body, &decoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + args := decoded["params"].(map[string]any)["arguments"].(map[string]any) + if _, has := args["include_ssn"]; has { + t.Fatalf("include_ssn should have been removed from args: %v", args) + } + if args["employee_id"] != "E001" { + t.Fatalf("employee_id lost in rewrite: %v", args) + } +} + +func TestMCPRequestBodyMod_PromptsGetArgsReplaced(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"weather","arguments":{"city":"SF"}}}`), + } + newArgs := map[string]any{"city": "REDACTED"} + mutated, err := applyMCPRequestBodyMod(pctx, "prompts/get", MCPRequestBodyMod{NewArguments: newArgs}) + if err != nil || !mutated { + t.Fatalf("mutated=%v err=%v", mutated, err) + } + if !strings.Contains(string(pctx.Body), `"city":"REDACTED"`) { + t.Fatalf("city not redacted in body: %s", pctx.Body) + } +} + +func TestMCPRequestBodyMod_ResourcesReadURIReplaced(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///secret"}}`), + } + mutated, err := applyMCPRequestBodyMod(pctx, "resources/read", MCPRequestBodyMod{NewURI: "file:///public"}) + if err != nil || !mutated { + t.Fatalf("mutated=%v err=%v", mutated, err) + } + if !strings.Contains(string(pctx.Body), `"uri":"file:///public"`) { + t.Fatalf("uri not rewritten: %s", pctx.Body) + } +} + +func TestMCPRequestBodyMod_EmptyArgsNoOp(t *testing.T) { + orig := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"a":1}}}`) + pctx := &pipeline.Context{Body: append([]byte(nil), orig...)} + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if mutated { + t.Fatal("expected mutated=false on empty mod") + } + if string(pctx.Body) != string(orig) { + t.Fatalf("body changed despite no-op: %s", pctx.Body) + } +} + +func TestMCPRequestBodyMod_UnsupportedMethodNoOp(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), + } + mutated, err := applyMCPRequestBodyMod(pctx, "initialize", MCPRequestBodyMod{NewArguments: map[string]any{"x": 1}}) + if err != nil { + t.Fatalf("err: %v", err) + } + if mutated { + t.Fatal("expected mutated=false for unsupported method") + } +} + +func TestMCPRequestBodyMod_MalformedJSONError(t *testing.T) { + pctx := &pipeline.Context{Body: []byte(`{not json`)} + _, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}}) + if err == nil { + t.Fatal("expected error on malformed JSON") + } +} + +func TestMCPRequestBodyMod_EmptyBodyNoOp(t *testing.T) { + pctx := &pipeline.Context{} + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}}) + if err != nil || mutated { + t.Fatalf("mutated=%v err=%v on empty body", mutated, err) + } +} + +func TestMCPRequestBodyMod_NoParamsObjectNoOp(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`), + } + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}}) + if err != nil || mutated { + t.Fatalf("mutated=%v err=%v on body without params", mutated, err) + } +} + +// --- applyMCPResponseBodyMod --- + +func TestMCPResponseBodyMod_TextBlockReplaced(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"original payload with ssn:123-45-6789"}]}}`), + } + newContent := map[string]any{ + "employee_id": "E001", + "name": "Jane Smith", + // SSN removed + } + mutated, err := applyMCPResponseBodyMod(pctx, "tools/call", newContent) + if err != nil { + t.Fatalf("err: %v", err) + } + if !mutated { + t.Fatal("expected mutated=true") + } + // The new text block is JSON-stringified newContent (a nested + // JSON-in-JSON shape), so the inner quotes appear escaped. + body := string(pctx.ResponseBody) + if !strings.Contains(body, `\"employee_id\"`) || !strings.Contains(body, `\"Jane Smith\"`) { + t.Fatalf("new content not present in response: %s", body) + } + if strings.Contains(body, "123-45-6789") { + t.Fatalf("old SSN still present in rewritten response: %s", body) + } +} + +func TestMCPResponseBodyMod_StructuredContentUpdated(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"x"}],"structuredContent":{"ssn":"123"}}}`), + } + newContent := map[string]any{"name": "Jane"} + if _, err := applyMCPResponseBodyMod(pctx, "tools/call", newContent); err != nil { + t.Fatalf("err: %v", err) + } + var decoded map[string]any + json.Unmarshal(pctx.ResponseBody, &decoded) + result := decoded["result"].(map[string]any) + sc := result["structuredContent"].(map[string]any) + if sc["ssn"] != nil { + t.Fatalf("structuredContent.ssn should be gone: %v", sc) + } + if sc["name"] != "Jane" { + t.Fatalf("structuredContent.name not updated: %v", sc) + } +} + +func TestMCPResponseBodyMod_NoStructuredContentNotIntroduced(t *testing.T) { + // When the original response didn't have structuredContent, don't + // introduce it on rewrite — clients sniffing for the new shape + // would be surprised. + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"x"}]}}`), + } + if _, err := applyMCPResponseBodyMod(pctx, "tools/call", map[string]any{"name": "Jane"}); err != nil { + t.Fatalf("err: %v", err) + } + if strings.Contains(string(pctx.ResponseBody), "structuredContent") { + t.Fatalf("structuredContent introduced when original didn't have it: %s", pctx.ResponseBody) + } +} + +func TestMCPResponseBodyMod_NotToolsCallNoOp(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}`), + } + mutated, err := applyMCPResponseBodyMod(pctx, "tools/list", map[string]any{"x": 1}) + if err != nil || mutated { + t.Fatalf("mutated=%v err=%v for non-tools/call method", mutated, err) + } +} + +func TestMCPResponseBodyMod_EmptyBodyNoOp(t *testing.T) { + pctx := &pipeline.Context{} + mutated, err := applyMCPResponseBodyMod(pctx, "tools/call", map[string]any{"x": 1}) + if err != nil || mutated { + t.Fatalf("mutated=%v err=%v on empty body", mutated, err) + } +} + +func TestMCPResponseBodyMod_NilContentNoOp(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"x"}]}}`), + } + mutated, err := applyMCPResponseBodyMod(pctx, "tools/call", nil) + if err != nil || mutated { + t.Fatalf("mutated=%v err=%v on nil newContent", mutated, err) + } +} + +func TestMCPResponseBodyMod_MalformedJSONError(t *testing.T) { + // Mirrors TestMCPRequestBodyMod_MalformedJSONError on the response + // side: an unparseable response body is an error, not a silent no-op. + pctx := &pipeline.Context{ResponseBody: []byte(`{not json`)} + _, err := applyMCPResponseBodyMod(pctx, "tools/call", map[string]any{"x": 1}) + if err == nil { + t.Fatal("expected error on malformed response JSON") + } +} + +func TestMCPResponseBodyMod_NoTextBlockNoMutation(t *testing.T) { + // Response had only image/audio blocks; no text block to replace + // and no structuredContent to update → no rewrite. + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"image","data":"abc"}]}}`), + } + mutated, err := applyMCPResponseBodyMod(pctx, "tools/call", map[string]any{"x": 1}) + if err != nil || mutated { + t.Fatalf("mutated=%v err=%v when no text block to replace", mutated, err) + } +} + +// --- stringifyForTextBlock --- + +func TestStringifyForTextBlock(t *testing.T) { + cases := []struct { + in any + want string + }{ + {"plain text", "plain text"}, + {map[string]any{"k": "v"}, "{\n \"k\": \"v\"\n}"}, + {[]int{1, 2}, "[\n 1,\n 2\n]"}, + } + for _, tc := range cases { + got := stringifyForTextBlock(tc.in) + if got != tc.want { + t.Errorf("stringifyForTextBlock(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// --- mcpToCMFPart: request phase --- + +func TestMCPToCMFPart_ToolsCallRequest(t *testing.T) { + mcp := &pipeline.MCPExtension{ + Method: "tools/call", + RPCID: float64(7), // JSON numbers decode to float64 + Params: map[string]any{ + "name": "get_compensation", + "arguments": map[string]any{"employee_id": "EMP-1", "include_ssn": true}, + }, + } + got := mcpToCMFPart(mcp, false, []byte(`{"raw":"req"}`), nil) + if got.Kind != cmfPartToolCall { + t.Fatalf("Kind = %v, want cmfPartToolCall", got.Kind) + } + if got.Name != "get_compensation" { + t.Errorf("Name = %q, want get_compensation", got.Name) + } + if got.CorrelationID != "7" { + t.Errorf("CorrelationID = %q, want 7", got.CorrelationID) + } + if got.Arguments["employee_id"] != "EMP-1" { + t.Errorf("Arguments lost employee_id: %v", got.Arguments) + } +} + +func TestMCPToCMFPart_PromptsGetRequest(t *testing.T) { + mcp := &pipeline.MCPExtension{ + Method: "prompts/get", + RPCID: "abc", + Params: map[string]any{"name": "weather", "arguments": map[string]any{"city": "SF"}}, + } + got := mcpToCMFPart(mcp, false, nil, nil) + if got.Kind != cmfPartPromptRequest { + t.Fatalf("Kind = %v, want cmfPartPromptRequest", got.Kind) + } + if got.Name != "weather" || got.Arguments["city"] != "SF" || got.CorrelationID != "abc" { + t.Errorf("unexpected part: %+v", got) + } +} + +func TestMCPToCMFPart_ResourcesReadRequest(t *testing.T) { + mcp := &pipeline.MCPExtension{ + Method: "resources/read", + Params: map[string]any{"uri": "file:///secret"}, + } + got := mcpToCMFPart(mcp, false, nil, nil) + if got.Kind != cmfPartResourceRef { + t.Fatalf("Kind = %v, want cmfPartResourceRef", got.Kind) + } + if got.URI != "file:///secret" { + t.Errorf("URI = %q, want file:///secret", got.URI) + } +} + +func TestMCPToCMFPart_NonActionFallsBackToText(t *testing.T) { + mcp := &pipeline.MCPExtension{Method: "tools/list"} + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) + got := mcpToCMFPart(mcp, false, body, nil) + if got.Kind != cmfPartText { + t.Fatalf("Kind = %v, want cmfPartText", got.Kind) + } + if got.Text != string(body) { + t.Errorf("Text = %q, want raw body", got.Text) + } +} + +func TestMCPToCMFPart_NilExtensionFallsBackToText(t *testing.T) { + got := mcpToCMFPart(nil, false, []byte("opaque"), nil) + if got.Kind != cmfPartText || got.Text != "opaque" { + t.Fatalf("nil MCP ext: got %+v, want text 'opaque'", got) + } +} + +// --- mcpToCMFPart: response phase --- + +func TestMCPToCMFPart_ToolsCallResponse(t *testing.T) { + // Method/Params persist from the request; the tool result is parsed + // from the response body (NOT mcp.Result — cpex runs before mcp-parser + // on the response phase, so Result isn't populated yet). + mcp := &pipeline.MCPExtension{ + Method: "tools/call", + RPCID: float64(1), + Params: map[string]any{"name": "get_compensation"}, + } + respBody := []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"salary\":125000,\"ssn\":\"123-45-6789\"}"}]}}`) + got := mcpToCMFPart(mcp, true, nil, respBody) + if got.Kind != cmfPartToolResult { + t.Fatalf("Kind = %v, want cmfPartToolResult", got.Kind) + } + if got.Name != "get_compensation" { + t.Errorf("ToolName = %q, want get_compensation (preserved from request params)", got.Name) + } + inner, ok := got.Content.(map[string]any) + if !ok { + t.Fatalf("Content not parsed to object: %T %v", got.Content, got.Content) + } + if inner["ssn"] != "123-45-6789" { + t.Errorf("inner result missing ssn: %v", inner) + } +} + +func TestMCPToCMFPart_ToolsCallResponseError(t *testing.T) { + // A tools/call response carrying a JSON-RPC error (mcp.Err != nil) + // must surface as a ToolResult part with IsError=true so APL + // post-invoke policies can branch on tool failure. + mcp := &pipeline.MCPExtension{ + Method: "tools/call", + RPCID: float64(1), + Params: map[string]any{"name": "get_compensation"}, + Err: &pipeline.MCPError{Code: -32001, Message: "denied"}, + } + respBody := []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"denied"}}`) + got := mcpToCMFPart(mcp, true, nil, respBody) + if got.Kind != cmfPartToolResult { + t.Fatalf("Kind = %v, want cmfPartToolResult", got.Kind) + } + if !got.IsError { + t.Errorf("IsError = false, want true for a JSON-RPC error response") + } +} + +func TestMCPToCMFPart_NonToolsCallResponseFallsBackToText(t *testing.T) { + mcp := &pipeline.MCPExtension{Method: "resources/read"} + respBody := []byte(`{"jsonrpc":"2.0","id":1,"result":{"contents":[]}}`) + got := mcpToCMFPart(mcp, true, nil, respBody) + if got.Kind != cmfPartText || got.Text != string(respBody) { + t.Fatalf("want text fallback with response body, got %+v", got) + } +} + +func TestExtractToolResultFromBody(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"ssn\":\"x\",\"salary\":1}"}]}}`) + got := extractToolResultFromBody(body) + obj, ok := got.(map[string]any) + if !ok || obj["ssn"] != "x" { + t.Fatalf("expected inner {ssn:x,...}, got %v", got) + } + if extractToolResultFromBody(nil) != nil { + t.Error("nil body → nil") + } + if extractToolResultFromBody([]byte("not json")) != nil { + t.Error("malformed body → nil") + } +} + +// --- extractToolResultContent --- + +func TestExtractToolResultContent_StructuredContentPreferred(t *testing.T) { + result := map[string]any{ + "structuredContent": map[string]any{"salary": 100}, + "content": []any{map[string]any{"type": "text", "text": `{"salary":999}`}}, + } + got := extractToolResultContent(result) + obj, ok := got.(map[string]any) + if !ok || obj["salary"] != 100 { + t.Fatalf("structuredContent should win: %v", got) + } +} + +func TestExtractToolResultContent_TextBlockParsedAsJSON(t *testing.T) { + result := map[string]any{ + "content": []any{map[string]any{"type": "text", "text": `{"ssn":"x"}`}}, + } + got := extractToolResultContent(result) + obj, ok := got.(map[string]any) + if !ok || obj["ssn"] != "x" { + t.Fatalf("text block should parse as JSON object: %v", got) + } +} + +func TestExtractToolResultContent_NonJSONTextWrapped(t *testing.T) { + result := map[string]any{ + "content": []any{map[string]any{"type": "text", "text": "not json"}}, + } + got := extractToolResultContent(result) + obj, ok := got.(map[string]any) + if !ok || obj["text"] != "not json" { + t.Fatalf("non-JSON text should wrap as {text:...}: %v", got) + } +} + +func TestExtractToolResultContent_Nil(t *testing.T) { + if got := extractToolResultContent(nil); got != nil { + t.Fatalf("nil result → nil content, got %v", got) + } +} + +// --- cmfEntity (route-selection coordinates) --- + +func TestCMFEntity(t *testing.T) { + cases := []struct { + name string + part cmfPart + wantType, wantName string + }{ + {"tool_call", cmfPart{Kind: cmfPartToolCall, Name: "get_compensation"}, "tool", "get_compensation"}, + {"tool_result", cmfPart{Kind: cmfPartToolResult, Name: "get_compensation"}, "tool", "get_compensation"}, + {"prompt", cmfPart{Kind: cmfPartPromptRequest, Name: "weather"}, "prompt", "weather"}, + {"resource", cmfPart{Kind: cmfPartResourceRef, URI: "file:///x"}, "resource", "file:///x"}, + {"text_fallback", cmfPart{Kind: cmfPartText, Text: "x"}, "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + et, en := cmfEntity(tc.part) + if et != tc.wantType || en != tc.wantName { + t.Errorf("cmfEntity(%s) = (%q,%q), want (%q,%q)", tc.name, et, en, tc.wantType, tc.wantName) + } + }) + } +} + +// --- stringifyRPCID --- + +func TestStringifyRPCID(t *testing.T) { + cases := []struct { + in any + want string + }{ + {nil, ""}, + {"req-9", "req-9"}, + {float64(1), "1"}, + {float64(1234567), "1234567"}, + {json.Number("42"), "42"}, + } + for _, tc := range cases { + if got := stringifyRPCID(tc.in); got != tc.want { + t.Errorf("stringifyRPCID(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// --- pctxToCMFParts selector --- + +func TestPctxToCMFParts_SelectsByPopulatedExtension(t *testing.T) { + t.Run("mcp", func(t *testing.T) { + pctx := &pipeline.Context{ + Extensions: pipeline.Extensions{MCP: &pipeline.MCPExtension{ + Method: "tools/call", + Params: map[string]any{"name": "get_compensation", "arguments": map[string]any{"id": "E1"}}, + }}, + } + parts, et, en := pctxToCMFParts(pctx, false) + if len(parts) != 1 || parts[0].Kind != cmfPartToolCall { + t.Fatalf("mcp parts: %+v", parts) + } + if et != "tool" || en != "get_compensation" { + t.Fatalf("mcp entity = (%q,%q)", et, en) + } + }) + + t.Run("inference", func(t *testing.T) { + pctx := &pipeline.Context{ + Extensions: pipeline.Extensions{Inference: &pipeline.InferenceExtension{ + Model: "gpt-4o", + Messages: []pipeline.InferenceMessage{{Role: "user", Content: "hi"}}, + }}, + } + parts, et, en := pctxToCMFParts(pctx, false) + if len(parts) != 1 || parts[0].Text != "hi" { + t.Fatalf("inference parts: %+v", parts) + } + if et != "model" || en != "gpt-4o" { + t.Fatalf("inference entity = (%q,%q)", et, en) + } + }) + + t.Run("a2a", func(t *testing.T) { + pctx := &pipeline.Context{ + Extensions: pipeline.Extensions{A2A: &pipeline.A2AExtension{ + Method: "message/send", + Parts: []pipeline.A2APart{{Kind: "text", Content: "yo"}}, + }}, + } + parts, et, en := pctxToCMFParts(pctx, false) + if len(parts) != 1 || parts[0].Text != "yo" { + t.Fatalf("a2a parts: %+v", parts) + } + if et != "a2a_method" || en != "message/send" { + t.Fatalf("a2a entity = (%q,%q)", et, en) + } + }) + + t.Run("none falls back to opaque text", func(t *testing.T) { + pctx := &pipeline.Context{Body: []byte("raw body")} + parts, et, en := pctxToCMFParts(pctx, false) + if len(parts) != 1 || parts[0].Kind != cmfPartText || parts[0].Text != "raw body" { + t.Fatalf("fallback parts: %+v", parts) + } + if et != "" || en != "" { + t.Fatalf("fallback should set no entity, got (%q,%q)", et, en) + } + }) +} + +func TestInferenceHistory_RoleTagged(t *testing.T) { + inf := &pipeline.InferenceExtension{Messages: []pipeline.InferenceMessage{ + {Role: "system", Content: "s"}, + {Role: "user", Content: "u"}, + {Role: "assistant", Content: ""}, // dropped + }} + hist := inferenceHistory(inf) + if len(hist) != 2 { + t.Fatalf("want 2 history entries, got %d: %+v", len(hist), hist) + } + if hist[0]["role"] != "system" || hist[0]["content"] != "s" { + t.Fatalf("entry0 = %+v", hist[0]) + } + if hist[1]["role"] != "user" || hist[1]["content"] != "u" { + t.Fatalf("entry1 = %+v", hist[1]) + } +} + +func TestSessionIDFromHeaders(t *testing.T) { + h := http.Header{} + if got := sessionIDFromHeaders(h); got != "" { + t.Fatalf("absent header should yield empty, got %q", got) + } + h.Set("X-Session-Id", "taint-demo-42") + if got := sessionIDFromHeaders(h); got != "taint-demo-42" { + t.Fatalf("got %q, want taint-demo-42", got) + } +} diff --git a/authbridge/authlib/plugins/cpex/cmf_inference.go b/authbridge/authlib/plugins/cpex/cmf_inference.go new file mode 100644 index 000000000..7d3d45dc2 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_inference.go @@ -0,0 +1,202 @@ +package cpex + +import ( + "encoding/json" + "fmt" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// Inference (OpenAI chat/completions) body write-back. The cgo adapter +// (manager_cpex.go) extracts the redacted text from a CPEX-modified CMF +// Message and hands it here; these re-serializers re-parse the ORIGINAL +// request/response envelope and replace only the message/completion text, +// so every other field the upstream cares about (model, temperature, +// tools, usage, …) survives untouched. Mirrors the MCP re-serializers in +// cmf_body.go; kept tag-free so it's unit-tested under CGO_ENABLED=0. + +// inferenceResponseParts extracts the structured CMF content parts from a +// non-streaming OpenAI chat/completions response body. Used on the +// response phase, where cpex runs before inference-parser and so must +// parse the body itself rather than read the not-yet-populated +// inf.Completion / inf.ToolCalls (mirrors extractToolResultFromBody for +// MCP). +// +// Emits an assistant text part per choice with string content (redactable +// via cmf.llm_output) plus a tool_call part per tool call the model +// emitted. Streaming (SSE) bodies don't parse as one JSON object and yield +// nil — there's no structured content to redact, and the matching +// write-back fails closed on SSE. +func inferenceResponseParts(body []byte) []cmfPart { + if len(body) == 0 { + return nil + } + var envelope map[string]any + if json.Unmarshal(body, &envelope) != nil { + return nil + } + choices, _ := envelope["choices"].([]any) + var parts []cmfPart + for _, ch := range choices { + choice, ok := ch.(map[string]any) + if !ok { + continue + } + msg, ok := choice["message"].(map[string]any) + if !ok { + continue + } + if c, ok := msg["content"].(string); ok && c != "" { + parts = append(parts, cmfPart{Kind: cmfPartText, Text: c}) + } + toolCalls, _ := msg["tool_calls"].([]any) + for _, t := range toolCalls { + to, ok := t.(map[string]any) + if !ok { + continue + } + fn, _ := to["function"].(map[string]any) + name, _ := fn["name"].(string) + if name == "" { + continue + } + argsStr, _ := fn["arguments"].(string) + id, _ := to["id"].(string) + parts = append(parts, cmfPart{ + Kind: cmfPartToolCall, + Name: name, + Arguments: parseToolArgs(argsStr), + CorrelationID: id, + }) + } + } + return parts +} + +// applyInferenceRequestBodyMod rewrites pctx.Body — an OpenAI-style +// chat/completions request — replacing each message's string `content` +// positionally from newContents. newContents is the ordered list of +// redacted text parts CPEX returned, which the read path +// (inferenceToCMFParts) produced one-per-message for every message with +// non-empty string content, in document order. +// +// Alignment is enforced, not assumed: the count of body messages with +// non-empty string content MUST equal len(newContents). A mismatch means +// the body shape diverged from what the read path saw (e.g. multimodal +// array content, a message added/removed mid-pipeline), so rather than +// risk landing a redaction on the wrong message we return an error and +// let the caller fail closed. Messages whose content isn't a non-empty +// string (array/multimodal content, tool-call assistant turns with null +// content) are skipped on both sides and never counted. +// +// Returns mutated=true only when at least one content value actually +// changed; an all-identical rewrite is reported as mutated=false (no +// SetBody call), matching the MCP re-serializers' no-op contract. +func applyInferenceRequestBodyMod(pctx *pipeline.Context, newContents []string) (mutated bool, err error) { + if len(pctx.Body) == 0 { + return false, nil + } + var envelope map[string]any + if err := json.Unmarshal(pctx.Body, &envelope); err != nil { + return false, fmt.Errorf("decode inference request body as JSON: %w", err) + } + rawMsgs, ok := envelope["messages"].([]any) + if !ok { + // No messages array — nothing we know how to rewrite. Not an + // error: a request without messages simply has no redaction + // target (mirrors MCP's "no params" no-op). + return false, nil + } + + // Collect the string-content messages in order, so the count check + // and the assignment loop see exactly the same set. + targets := make([]map[string]any, 0, len(rawMsgs)) + for _, m := range rawMsgs { + mo, ok := m.(map[string]any) + if !ok { + continue + } + if c, ok := mo["content"].(string); ok && c != "" { + targets = append(targets, mo) + } + } + + if len(targets) != len(newContents) { + return false, fmt.Errorf( + "inference request message count drift: body has %d string-content messages, CPEX returned %d text parts", + len(targets), len(newContents)) + } + + changed := false + for i, mo := range targets { + if mo["content"] != newContents[i] { + mo["content"] = newContents[i] + changed = true + } + } + if !changed { + return false, nil + } + + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize inference request body: %w", err) + } + pctx.SetBody(newBody) + return true, nil +} + +// applyInferenceResponseBodyMod rewrites pctx.ResponseBody — a +// non-streaming OpenAI chat/completions response — replacing the +// assistant completion text with newCompletion (the single redacted text +// part CPEX returned on the response phase). +// +// It rewrites `choices[].message.content` for every choice that currently +// carries a string content. Streaming responses (SSE: a sequence of +// `data:` frames, not a single JSON object) are not rewritable here — the +// body won't parse as one JSON object, so we return an error and let the +// caller fail closed rather than forward an unredacted stream. +// +// Returns mutated=false (no error) when there's nothing to change: +// newCompletion empty, no choices, or no string content to replace. +func applyInferenceResponseBodyMod(pctx *pipeline.Context, newCompletion string) (mutated bool, err error) { + if len(pctx.ResponseBody) == 0 || newCompletion == "" { + return false, nil + } + var envelope map[string]any + if err := json.Unmarshal(pctx.ResponseBody, &envelope); err != nil { + // Likely an SSE stream or otherwise non-JSON body. A redaction + // was requested but can't be applied — fail closed. + return false, fmt.Errorf("decode inference response body as JSON (streaming responses are not rewritable): %w", err) + } + + choices, ok := envelope["choices"].([]any) + if !ok { + return false, nil + } + changed := false + for _, ch := range choices { + choice, ok := ch.(map[string]any) + if !ok { + continue + } + msg, ok := choice["message"].(map[string]any) + if !ok { + continue + } + if c, ok := msg["content"].(string); ok && c != "" { + msg["content"] = newCompletion + changed = true + } + } + if !changed { + return false, nil + } + + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize inference response body: %w", err) + } + pctx.SetResponseBody(newBody) + return true, nil +} diff --git a/authbridge/authlib/plugins/cpex/cmf_inference_test.go b/authbridge/authlib/plugins/cpex/cmf_inference_test.go new file mode 100644 index 000000000..3e06c605c --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_inference_test.go @@ -0,0 +1,186 @@ +package cpex + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// --- inferenceToCMFParts (request) --- + +func TestInferenceToCMFParts_RequestFlattensMessages(t *testing.T) { + inf := &pipeline.InferenceExtension{ + Model: "gpt-4o", + Messages: []pipeline.InferenceMessage{ + {Role: "system", Content: "be terse"}, + {Role: "user", Content: "my ssn is 123-45-6789"}, + {Role: "assistant", Content: ""}, // tool-call turn, no content — dropped + }, + } + parts := inferenceToCMFParts(inf, false, nil) + if len(parts) != 2 { + t.Fatalf("want 2 text parts (empty-content message dropped), got %d", len(parts)) + } + for i, want := range []string{"be terse", "my ssn is 123-45-6789"} { + if parts[i].Kind != cmfPartText || parts[i].Text != want { + t.Fatalf("part %d = %+v, want text %q", i, parts[i], want) + } + } +} + +// --- inferenceResponseParts (response phase parses body) --- + +func TestInferenceResponseParts_CompletionAndToolCalls(t *testing.T) { + body := []byte(`{ + "choices": [{ + "message": { + "role": "assistant", + "content": "here is the answer", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{\"id\":\"E1\"}"}} + ] + }, + "finish_reason": "stop" + }] + }`) + parts := inferenceResponseParts(body) + if len(parts) != 2 { + t.Fatalf("want completion + tool_call = 2 parts, got %d: %+v", len(parts), parts) + } + if parts[0].Kind != cmfPartText || parts[0].Text != "here is the answer" { + t.Fatalf("part0 = %+v, want completion text", parts[0]) + } + if parts[1].Kind != cmfPartToolCall || parts[1].Name != "lookup" { + t.Fatalf("part1 = %+v, want tool_call lookup", parts[1]) + } + if got := parts[1].Arguments["id"]; got != "E1" { + t.Fatalf("tool_call args id = %v, want E1", got) + } + if parts[1].CorrelationID != "call_1" { + t.Fatalf("tool_call id = %q, want call_1", parts[1].CorrelationID) + } +} + +func TestInferenceResponseParts_StreamingBodyYieldsNil(t *testing.T) { + // SSE frames don't parse as one JSON object. + sse := []byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n") + if parts := inferenceResponseParts(sse); parts != nil { + t.Fatalf("streaming body should yield nil parts, got %+v", parts) + } +} + +// --- applyInferenceRequestBodyMod --- + +func TestInferenceRequestBodyMod_RedactsPositionally(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"model":"gpt-4o","messages":[{"role":"system","content":"sys"},{"role":"user","content":"ssn 123-45-6789"}],"temperature":0.2}`), + } + mutated, err := applyInferenceRequestBodyMod(pctx, []string{"sys", "ssn [REDACTED]"}) + if err != nil || !mutated { + t.Fatalf("mutated=%v err=%v", mutated, err) + } + var decoded map[string]any + if err := json.Unmarshal(pctx.Body, &decoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + msgs := decoded["messages"].([]any) + if got := msgs[1].(map[string]any)["content"]; got != "ssn [REDACTED]" { + t.Fatalf("user content not redacted: %v", got) + } + // Untouched fields survive. + if decoded["temperature"].(float64) != 0.2 { + t.Fatalf("temperature lost: %v", decoded["temperature"]) + } +} + +func TestInferenceRequestBodyMod_CountDriftFailsClosed(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"messages":[{"role":"user","content":"a"},{"role":"user","content":"b"}]}`), + } + // Only one redacted text for two string-content messages → drift. + mutated, err := applyInferenceRequestBodyMod(pctx, []string{"a"}) + if err == nil { + t.Fatal("expected count-drift error (fail closed), got nil") + } + if mutated { + t.Fatal("must not mutate on drift") + } +} + +func TestInferenceRequestBodyMod_NoChangeReportsNoOp(t *testing.T) { + pctx := &pipeline.Context{ + Body: []byte(`{"messages":[{"role":"user","content":"unchanged"}]}`), + } + mutated, err := applyInferenceRequestBodyMod(pctx, []string{"unchanged"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if mutated { + t.Fatal("identical rewrite should report mutated=false") + } +} + +// --- applyInferenceResponseBodyMod --- + +func TestInferenceResponseBodyMod_RewritesChoiceContent(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"choices":[{"message":{"role":"assistant","content":"leaked 123-45-6789"},"finish_reason":"stop"}],"usage":{"total_tokens":9}}`), + } + mutated, err := applyInferenceResponseBodyMod(pctx, "leaked [REDACTED]") + if err != nil || !mutated { + t.Fatalf("mutated=%v err=%v", mutated, err) + } + var decoded map[string]any + if err := json.Unmarshal(pctx.ResponseBody, &decoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + msg := decoded["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != "leaked [REDACTED]" { + t.Fatalf("completion not redacted: %v", msg["content"]) + } +} + +func TestInferenceResponseBodyMod_StreamingFailsClosed(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n"), + } + mutated, err := applyInferenceResponseBodyMod(pctx, "redacted") + if err == nil { + t.Fatal("streaming response rewrite should fail closed with an error") + } + if mutated { + t.Fatal("must not mutate a streaming response") + } +} + +// --- parseToolArgs / inferProvider --- + +func TestParseToolArgs(t *testing.T) { + if got := parseToolArgs(`{"a":1}`); !reflect.DeepEqual(got, map[string]any{"a": float64(1)}) { + t.Fatalf("valid json: %v", got) + } + if got := parseToolArgs("not json"); got["_raw"] != "not json" { + t.Fatalf("malformed should wrap as _raw: %v", got) + } + if got := parseToolArgs(""); got != nil { + t.Fatalf("empty should be nil: %v", got) + } +} + +func TestInferProvider(t *testing.T) { + cases := map[string]string{ + "gpt-4o": "openai", + "o1-preview": "openai", + "claude-3-5-sonnet": "anthropic", + "gemini-1.5-pro": "google", + "llama3": "", + "granite-3": "", + } + for model, want := range cases { + if got := inferProvider(model); got != want { + t.Errorf("inferProvider(%q) = %q, want %q", model, got, want) + } + } +} diff --git a/authbridge/authlib/plugins/cpex/config.go b/authbridge/authlib/plugins/cpex/config.go new file mode 100644 index 000000000..02efe2e25 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/config.go @@ -0,0 +1,194 @@ +package cpex + +import ( + "errors" + "fmt" + "os" + "path" + "strings" +) + +// cpexConfig is the operator-facing schema for the cpex plugin. The +// decode → applyDefaults → validate → build pattern follows +// authbridge/docs/plugin-reference.md. +// +// Operators express which CPEX hooks fire on each AuthBridge phase +// via the `hooks` block. The CPEX runtime config (the `plugins:` / +// `global:` / `plugin_settings:` YAML that CPEX itself parses) is +// supplied either inline via `config` or by path via `config_file`. +// Operators read CPEX docs verbatim and the YAML format is what they +// would write for CPEX directly — no kagenti-side re-shaping. +// +// A cpex plugin with no `hooks`, no `config`, and no `config_file` is +// installed but inert — Configure succeeds; OnRequest/OnResponse +// return Continue immediately. That makes "install cpex now, ship the +// CPEX YAML update later" a one-step rollout. +type cpexConfig struct { + // Hooks selects which CPEX hook names fire on each AuthBridge + // pipeline phase. Empty slices mean the plugin is a no-op for + // that phase. Hooks fire in declaration order; the chain + // short-circuits on the first sub-plugin that returns deny. + Hooks hooksConfig `json:"hooks" description:"AuthBridge phase → CPEX hook list. on_request fires during pctx.OnRequest; on_response fires during pctx.OnResponse."` + + // Config is the CPEX runtime YAML inline — what CPEX's own docs + // describe (top-level `plugins:`, `global:`, `plugin_settings:`). + // Mutually exclusive with ConfigFile. Convenient for small demos; + // production usually mounts a ConfigMap and references it via + // ConfigFile so reloads don't require a sidecar redeploy. + Config string `json:"config,omitempty" description:"CPEX YAML inline (plugins:/global:/...). Mutually exclusive with config_file."` + + // ConfigFile is a path to a file containing the CPEX runtime + // YAML. Mutually exclusive with Config. The file is read once + // at Configure time; hot-reload on file change is a follow-up. + ConfigFile string `json:"config_file,omitempty" description:"Path to CPEX YAML file. Mutually exclusive with config."` + + // FailOpen controls behavior when CPEX itself errors or panics + // during invoke. Note: a CPEX policy `deny` is a normal outcome, + // not an error, and is always honored. + // + // false (default) — CPEX error → AuthBridge denies with 502. + // true — CPEX error → request continues, error logged. + // + // Default is fail-closed because CPEX errors usually mean a + // misconfigured policy or a backend (PDP, JWKS) is unreachable — + // silently allowing traffic in that state is rarely what operators + // want. Set true only when an upstream layer has its own enforcement. + FailOpen bool `json:"fail_open" default:"false" description:"Allow traffic when CPEX itself errors (default false: fail closed)."` + + // WorkerThreads sets the size of CPEX's tokio worker pool. Default + // 0 means CPEX picks based on available CPUs. Set a small positive + // value (e.g. 2) to bound sidecar CPU usage in dense node + // deployments. + WorkerThreads int `json:"worker_threads" default:"0" description:"CPEX tokio worker pool size. 0 = automatic."` + + // BypassHosts is a list of host glob patterns (path.Match syntax) + // whose requests skip CPEX entirely — no FFI crossing, no policy + // evaluation. Sensible defaults cover sidecar infrastructure that + // shouldn't be policy-gated (Keycloak, SPIRE, observability stack). + // Operators extend this list for their own internal services. + // + // Refuse to bypass with `*` or `""` — that's an "I want to disable + // the plugin" gesture which is better expressed by removing cpex + // from the pipeline. + BypassHosts []string `json:"bypass_hosts" description:"Host globs whose requests skip CPEX. Defaults to keycloak/SPIRE/observability."` + + // BypassPaths is a list of URL path glob patterns (path.Match + // syntax) whose requests skip CPEX entirely. Defaults to liveness/ + // readiness probes + .well-known discovery; operators add their + // own. Uses kagenti's shared bypass package so semantics match + // jwt-validation, ibac, etc. + BypassPaths []string `json:"bypass_paths" description:"URL path globs whose requests skip CPEX. Defaults to /healthz, /readyz, /livez, /.well-known/*."` +} + +// hooksConfig declares which CPEX hooks the AuthBridge plugin +// invokes on each phase. Lists are evaluated in order; the plugin +// short-circuits on the first sub-plugin that returns deny. Use the +// HookToolPreInvoke / HookToolPostInvoke / HookLLMInput / +// HookLLMOutput constants for hook names — they match what CPEX's +// CMF dispatcher recognizes. +type hooksConfig struct { + OnRequest []string `json:"on_request,omitempty" description:"CPEX hook names fired during AuthBridge OnRequest, in order."` + OnResponse []string `json:"on_response,omitempty" description:"CPEX hook names fired during AuthBridge OnResponse, in order."` +} + +// defaultBypassHosts is the conservative starting set: sidecar +// infrastructure that should never see CPEX policy evaluation. Mirrors +// the ibac convention so operators get consistent behavior across +// plugins. +var defaultBypassHosts = []string{ + "keycloak.*", + "keycloak", + "spire-server.*", + "spire-agent.*", + "otel-collector.*", + "jaeger.*", + "prometheus.*", +} + +// applyDefaults fills in zero-valued fields with their documented +// defaults. Called by Configure between decode and validate. +// +// Hooks default to empty — operators must opt in explicitly. A no-op +// plugin (no hooks configured) is a valid steady state; the operator +// updates the YAML to enable policies when ready. +// +// BypassHosts and BypassPaths default to the conservative +// infrastructure sets when the operator left them unset; an +// operator who genuinely wants empty bypass lists must opt out +// with `bypass_hosts: []` / `bypass_paths: []`. +func (c *cpexConfig) applyDefaults() { + if c.BypassHosts == nil { + c.BypassHosts = append([]string(nil), defaultBypassHosts...) + } + // c.BypassPaths is filled lazily from bypass.DefaultPatterns + // in plugin.go's Configure (avoids importing bypass here). +} + +// validate rejects configs that would fail at boot or at first +// request. Each error names the offending JSON field so operators +// can locate the typo in their YAML. +// +// No config combination is REQUIRED — a fully-empty config produces +// a valid no-op plugin. The validation here only catches positively +// broken inputs (negative numbers, `*` bypass patterns, empty hook +// names, Config + ConfigFile both set). +func (c *cpexConfig) validate() error { + if c.Config != "" && c.ConfigFile != "" { + return errors.New("`config` and `config_file` are mutually exclusive; set at most one") + } + if c.WorkerThreads < 0 { + return fmt.Errorf("worker_threads must be >= 0, got %d", c.WorkerThreads) + } + for _, h := range c.Hooks.OnRequest { + if strings.TrimSpace(h) == "" { + return fmt.Errorf("hooks.on_request: empty hook name") + } + } + for _, h := range c.Hooks.OnResponse { + if strings.TrimSpace(h) == "" { + return fmt.Errorf("hooks.on_response: empty hook name") + } + } + for _, p := range c.BypassHosts { + if _, err := path.Match(p, ""); err != nil { + return fmt.Errorf("invalid bypass_hosts pattern %q: %w", p, err) + } + if trimmed := strings.TrimSpace(p); trimmed == "" || trimmed == "*" { + return fmt.Errorf("bypass_hosts pattern %q matches everything; "+ + "if you mean to disable CPEX, remove it from the pipeline instead", p) + } + } + for _, p := range c.BypassPaths { + if _, err := path.Match(p, "/"); err != nil { + return fmt.Errorf("invalid bypass_paths pattern %q: %w", p, err) + } + if trimmed := strings.TrimSpace(p); trimmed == "" || trimmed == "*" || trimmed == "/*" { + return fmt.Errorf("bypass_paths pattern %q matches everything; "+ + "if you mean to disable CPEX, remove it from the pipeline instead", p) + } + } + return nil +} + +// resolveYAML returns the CPEX YAML string the operator supplied — +// either Config verbatim or the contents of ConfigFile. Called once +// during Configure, after validate. Returns "" when neither is set +// (a valid no-op install). +// +// The file read happens here rather than in validate so a transient +// I/O error surfaces as a Configure error (caught by Pipeline.Build, +// fails the boot loudly) rather than a validation error (which +// operators read as "your YAML is wrong"). +func (c *cpexConfig) resolveYAML() (string, error) { + if c.Config != "" { + return c.Config, nil + } + if c.ConfigFile == "" { + return "", nil + } + b, err := os.ReadFile(c.ConfigFile) + if err != nil { + return "", fmt.Errorf("read config_file %q: %w", c.ConfigFile, err) + } + return string(b), nil +} diff --git a/authbridge/authlib/plugins/cpex/headers.go b/authbridge/authlib/plugins/cpex/headers.go new file mode 100644 index 000000000..42a580a47 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/headers.go @@ -0,0 +1,122 @@ +package cpex + +import ( + "net/http" + "strings" +) + +// secretHeaderPrefixes lists header-name prefixes that are NEVER +// forwarded into CPEX. These are session cookies and platform-issued +// internal secrets that no CPEX plugin ever legitimately needs. +// +// This is NOT the only line of defense, and it does not replace +// CPEX's capability model. The `http` extension slot is +// capability-gated (`read_headers`); an audit/logger plugin SHOULD be +// configured without that capability so `filter_extensions` blinds it +// to the slot wholesale. Do that too. The boundary strip is +// complementary, for two gaps the capability gate cannot close: +// +// 1. `read_headers` is whole-slot — a plugin that legitimately +// needs ONE header (the jwt plugin reading `Authorization`, a +// router reading `Host`) must hold `read_headers` and thereby +// sees EVERY header, including cookies and api-keys. The gate +// can't say "Authorization yes, Cookie no"; this strip can. +// 2. Capability filtering happens at per-plugin dispatch only — the +// canonical Extensions captured into the (unauthenticated) +// session API are UNFILTERED. A secret that enters the `http` +// slot is exposed there regardless of any plugin's caps. +// +// Stripping at this boundary keeps these secrets out of the canonical +// context entirely, so neither a misconfigured capability grant, a +// future plugin that declares `read_headers`, nor the session surface +// can leak them — the same posture CPEX itself uses for raw tokens +// (`#[serde(skip)]` AND a capability gate). +// +// NOTE on `Authorization`: deliberately NOT stripped. CPEX's +// identity/jwt plugins (jwt-client, etc.) read the bearer token from +// the Authorization header to validate signature, audience, expiry, +// and to extract role/perm/team/group claims that APL predicates +// (`require(role.hr)`, `redact(!perm.view_ssn)`, …) gate on. Strip +// it and you lose every gate that depends on the client identity — +// the request continues to evaluate against an empty client bag, +// which silently allows traffic the policy meant to deny. +// +// The audit-log risk this opens — bearer tokens reaching audit +// payloads — is mitigated by configuring audit-log to drop +// Authorization from its output (or by terminating TLS at the +// sidecar so tokens are short-lived and bound to mTLS). +// +// This table and the helpers below are tag-free (no //go:build cpex) +// so the default CGO_ENABLED=0 test build exercises them; the cgo +// adapter in manager_cpex.go is their only production caller. +var secretHeaderPrefixes = []string{ + "cookie", + "set-cookie", + "proxy-authorization", + "x-amz-security-token", +} + +// secretHeaderExact lists exact (case-insensitive) header names that +// must always be stripped. Used for one-off names that don't fit a +// prefix scheme. +var secretHeaderExact = map[string]struct{}{ + "x-api-key": {}, + "x-auth-token": {}, + "x-authorization": {}, + "x-secret-token": {}, + "x-session-token": {}, + "x-csrf-token": {}, + "x-platform-secret": {}, + "x-authbridge-secret": {}, +} + +// flattenHeaders converts http.Header (multi-value) into the single-value +// map shape CPEX's HttpExtension.RequestHeaders requires. Multi-value +// headers are comma-joined per RFC 7230 §3.2.2 — the standard +// safe-merge for repeatable HTTP headers. (Set-Cookie, which doesn't +// follow §3.2.2, lands in secretHeaderPrefixes and is stripped before +// reaching here.) +// +// Sensitive headers (Authorization, Cookie, X-Api-Key, …) are +// dropped via secretHeaderPrefixes / secretHeaderExact, NOT silently +// truncated. +func flattenHeaders(h http.Header) map[string]string { + if len(h) == 0 { + return nil + } + out := make(map[string]string, len(h)) + for k, vs := range h { + if isSensitive(k) || len(vs) == 0 { + continue + } + // RFC 7230 §3.2.2: repeatable HTTP headers combine with + // comma; non-repeatable ones either have a single value + // already or were already filtered (Set-Cookie). + if len(vs) == 1 { + out[k] = vs[0] + } else { + out[k] = strings.Join(vs, ", ") + } + } + if len(out) == 0 { + return nil + } + return out +} + +// isSensitive checks both the prefix and exact-name secret tables. +// Case-insensitive — HTTP header names are case-insensitive on the +// wire, and any policy that depends on the case of "Authorization" +// vs "authorization" is already broken. +func isSensitive(name string) bool { + lower := strings.ToLower(name) + if _, ok := secretHeaderExact[lower]; ok { + return true + } + for _, prefix := range secretHeaderPrefixes { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} diff --git a/authbridge/authlib/plugins/cpex/headers_test.go b/authbridge/authlib/plugins/cpex/headers_test.go new file mode 100644 index 000000000..2eb5ec437 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/headers_test.go @@ -0,0 +1,76 @@ +package cpex + +import ( + "net/http" + "testing" +) + +func TestIsSensitive(t *testing.T) { + cases := []struct { + name string + want bool + }{ + // Authorization deliberately passes through (jwt resolvers need it). + {"Authorization", false}, + {"authorization", false}, + {"Content-Type", false}, + {"X-Request-Id", false}, + // Stripped (case-insensitive, exact + prefix). + {"Cookie", true}, + {"cookie", true}, + {"Set-Cookie", true}, + {"X-Api-Key", true}, + {"x-api-key", true}, + {"X-CSRF-Token", true}, + {"x-csrf-token", true}, + {"Proxy-Authorization", true}, + {"X-Amz-Security-Token-Extra", true}, // prefix match + } + for _, tc := range cases { + if got := isSensitive(tc.name); got != tc.want { + t.Errorf("isSensitive(%q) = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestFlattenHeaders(t *testing.T) { + h := http.Header{ + "Authorization": {"Bearer abc"}, + "Cookie": {"session=xyz"}, + "X-Api-Key": {"secret"}, + "X-Csrf-Token": {"t"}, + "Content-Type": {"application/json"}, + "X-Multi": {"a", "b"}, + } + out := flattenHeaders(h) + + // Authorization passes through. + if out["Authorization"] != "Bearer abc" { + t.Errorf("Authorization should pass through, got %q", out["Authorization"]) + } + // Sensitive headers stripped (case-insensitively). + for _, k := range []string{"Cookie", "X-Api-Key", "X-Csrf-Token"} { + if _, ok := out[k]; ok { + t.Errorf("%s should have been stripped: %v", k, out) + } + } + // Plain header preserved. + if out["Content-Type"] != "application/json" { + t.Errorf("Content-Type lost: %v", out) + } + // Multi-value comma-joined per RFC 7230 §3.2.2. + if out["X-Multi"] != "a, b" { + t.Errorf("X-Multi = %q, want %q", out["X-Multi"], "a, b") + } +} + +func TestFlattenHeaders_Empty(t *testing.T) { + if flattenHeaders(nil) != nil { + t.Error("nil header → nil map") + } + // All-sensitive input yields nil (not an empty map). + only := http.Header{"Cookie": {"x"}, "X-Api-Key": {"y"}} + if flattenHeaders(only) != nil { + t.Errorf("all-sensitive header set → nil, got %v", flattenHeaders(only)) + } +} diff --git a/authbridge/authlib/plugins/cpex/hooks.go b/authbridge/authlib/plugins/cpex/hooks.go new file mode 100644 index 000000000..837368175 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/hooks.go @@ -0,0 +1,30 @@ +package cpex + +// CPEX hook names. AuthBridge dispatches operator-configured subsets +// of these on each pipeline phase via cpexConfig.Hooks. The exact set +// CPEX accepts is governed by whichever sub-plugins are registered on +// the manager; validation of unknown hook names happens at LoadConfig +// time on the CPEX side. +// +// Exported so operators reading the AuthBridge plugin's config schema +// — and out-of-tree code that wants to build hook lists +// programmatically — refer to the same names CPEX uses. +const ( + // HookToolPreInvoke fires before an MCP tool call is forwarded. + // Canonical APL home for tool-args validation (validator/pii-scan) + // and PDP checks (pdp/cedar-direct, pdp/cedarling). + HookToolPreInvoke = "cmf.tool_pre_invoke" + + // HookToolPostInvoke fires after an MCP tool call returns, before + // the result reaches the agent. Canonical APL home for audit/logger + // and post-call delegators. + HookToolPostInvoke = "cmf.tool_post_invoke" + + // HookLLMInput fires on LLM inference request before it leaves the + // pod. Canonical APL home for prompt-PII redactors. + HookLLMInput = "cmf.llm_input" + + // HookLLMOutput fires on LLM inference response before it reaches + // the agent. Canonical APL home for output redactors and audit. + HookLLMOutput = "cmf.llm_output" +) diff --git a/authbridge/authlib/plugins/cpex/manager.go b/authbridge/authlib/plugins/cpex/manager.go new file mode 100644 index 000000000..08b8a6517 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager.go @@ -0,0 +1,167 @@ +package cpex + +import ( + "context" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// Manager is the surface plugin.go uses to talk to CPEX. It is a +// tag-free interface so unit tests can substitute a fake without +// requiring CGO_ENABLED or the libcpex_ffi.a static library. +// +// The real implementation lives in manager_cpex.go (//go:build cpex) +// and wraps the cpex.PluginManager from the +// github.com/IBM/contextforge-plugins-framework/go/cpex package. The +// stub in manager_stub.go (//go:build !cpex) makes NewManager error +// out with a clear "build the cpex binary" message if anyone tries to +// configure the plugin in a binary that wasn't built with -tags cpex. +// +// Lifecycle, matching pipeline.Plugin's: LoadConfig (during plugin +// Configure) → Initialize (during plugin Init) → many Invoke calls → +// Shutdown (during plugin Shutdown). HasHook is a cheap "is this hook +// wired up at all?" check used to skip Invoke when no sub-plugin +// would run. +type Manager interface { + // LoadConfig parses the operator's CPEX YAML and stages sub-plugin + // factories + routes. Called once. APL DSL factories are enabled + // before LoadConfig so the YAML may freely reference APL plugins. + LoadConfig(yaml string) error + + // Initialize starts the CPEX runtime — binds sub-plugin handlers + // to hooks, spawns background workers (JWKS refreshers, audit + // sinks). Called once after LoadConfig. + Initialize(ctx context.Context) error + + // Shutdown drains in-flight work and releases resources. Called + // once on pipeline stop; honor ctx's deadline. + Shutdown(ctx context.Context) + + // HasHook reports whether any loaded sub-plugin handles hookName. + // Lets OnRequest/OnResponse skip the cgo round-trip when no + // policy is configured for the current phase. + HasHook(hookName string) bool + + // Invoke runs hookName through every sub-plugin registered for + // it. The real implementation builds a CMF Message + Extensions + // from pctx, calls the cpex FFI, and (if policy modified the + // message) writes the changes back to pctx via pctx.SetBody / + // pctx.SetResponseBody / header mutation. The returned Result + // summarizes what happened for the Invocation record. + // + // Returning an error means CPEX itself failed (FFI error, runtime + // panic). A policy `deny` is NOT an error — it surfaces as + // Result.Decision == DecisionDeny with a populated Reason. + Invoke(ctx context.Context, hookName string, pctx *pipeline.Context) (Result, error) +} + +// ManagerOptions configures Manager construction. Fields here are +// process-wide knobs that don't belong in the YAML config because +// they affect runtime layout, not policy. +type ManagerOptions struct { + // WorkerThreads sets CPEX's tokio worker pool size. 0 = auto + // (CPEX picks based on CPU count). + WorkerThreads int +} + +// NewManager is defined in one of two build-tag-gated files: +// +// - manager_cpex.go (//go:build cpex) — returns a manager wrapping +// cpex.PluginManager; pulls in libcpex_ffi via cgo. +// - manager_stub.go (//go:build !cpex) — returns an error so a +// misconfigured binary fails loud at Configure time instead of +// silently registering a no-op plugin. +// +// The two files have mutually exclusive build tags, so exactly one +// NewManager symbol is defined per build. +// +// NewManager does NOT call LoadConfig or Initialize — those are the +// caller's responsibility, sequenced by the plugin's Configure / +// Init. + +// (NewManager is defined in manager_cpex.go / manager_stub.go.) + +// Decision is the outcome category of a CPEX Invoke. It maps onto +// the existing AuthBridge 5-value vocabulary (allow / deny / skip / +// modify / observe), plus an Error bucket for CPEX-internal failures +// that the plugin's fail_open knob governs. +type Decision int + +const ( + // DecisionAllow: all sub-plugins continued. Request proceeds + // untouched. + DecisionAllow Decision = iota + + // DecisionDeny: at least one sub-plugin returned a policy + // violation. Plugin emits pipeline.Deny. + DecisionDeny + + // DecisionModify: policy rewrote the message / headers. Plugin + // applies Modifications and continues. + DecisionModify + + // DecisionObserve: policy emitted observation-only outcomes + // (audit logged, no enforcement). Plugin records and continues. + DecisionObserve + + // DecisionError: CPEX itself errored. The plugin's fail_open + // config decides whether to convert this to allow or deny. + DecisionError +) + +// String returns the lowercase decision name, used in Invocation +// reasons and log fields. +func (d Decision) String() string { + switch d { + case DecisionAllow: + return "allow" + case DecisionDeny: + return "deny" + case DecisionModify: + return "modify" + case DecisionObserve: + return "observe" + case DecisionError: + return "error" + default: + return "unknown" + } +} + +// Result is the aggregate outcome of a CPEX Invoke. Populated by the +// Manager from PipelineResult; consumed by plugin.go to drive +// pipeline.Action and Invocation emission. +type Result struct { + // Decision is the top-line outcome category. + Decision Decision + + // Reason is a short human-readable summary suitable for the + // Invocation.Reason field. For Deny, this is the deciding + // sub-plugin's violation message; for Modify, a one-line summary + // of what changed; for Error, the error string. + Reason string + + // PluginsRun lists CPEX sub-plugin names that executed for this + // hook, in declaration order. Always populated, even on Allow, + // so audit can show "what ran." + PluginsRun []string + + // Errors carries per-sub-plugin error records from CPEX's + // PipelineResult.errors. Populated when at least one sub-plugin + // errored; otherwise empty. + Errors []SubPluginError + + // Code is an optional machine-readable identifier for the + // outcome — e.g. "cedar.denied", "pii.redacted". Surfaces in + // Invocation.Code when present. + Code string +} + +// SubPluginError is one CPEX sub-plugin's error report, lifted from +// PipelineResult.errors. The Plugin field is the CPEX sub-plugin +// name (not the AuthBridge plugin name — that's always "cpex"). +type SubPluginError struct { + Plugin string + Code string + Message string +} diff --git a/authbridge/authlib/plugins/cpex/manager_cpex.go b/authbridge/authlib/plugins/cpex/manager_cpex.go new file mode 100644 index 000000000..1bab0d99a --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager_cpex.go @@ -0,0 +1,851 @@ +//go:build cpex + +package cpex + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + rcpex "github.com/contextforge-org/cpex/go/cpex" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// cpexManager is the CPEX-backed Manager implementation. Wraps +// rcpex.PluginManager and translates pctx ↔ CMF on every Invoke. +// Lives behind //go:build cpex; the matching stub in manager_stub.go +// handles the !cpex case. +type cpexManager struct { + mgr *rcpex.PluginManager +} + +// NewManager constructs a CPEX-backed Manager. Tokio worker pool size +// (when > 0) is configured before manager creation. EnableAPL is called +// before any LoadConfig so APL DSL plugins are available to the +// operator's YAML. +// +// On any error before return, the partially-constructed manager is +// shut down so we don't leak the tokio runtime. +func NewManager(opts ManagerOptions) (Manager, error) { + if opts.WorkerThreads > 0 { + if err := rcpex.ConfigureRuntime(opts.WorkerThreads); err != nil { + return nil, fmt.Errorf("cpex configure runtime: %w", err) + } + } + mgr, err := rcpex.NewPluginManagerDefault() + if err != nil { + return nil, fmt.Errorf("cpex new manager: %w", err) + } + if err := mgr.EnableAPL(); err != nil { + mgr.Shutdown() + return nil, fmt.Errorf("cpex enable APL: %w", err) + } + return &cpexManager{mgr: mgr}, nil +} + +func (c *cpexManager) LoadConfig(yaml string) error { + return c.mgr.LoadConfig(yaml) +} + +// Initialize honors ctx's deadline. The rcpex binding's Initialize is +// blocking and NOT context-aware (no ctx param; see +// PluginManager.Initialize), so we run it in a goroutine and select on +// ctx.Done vs completion. On ctx cancellation we return ctx.Err() so +// the caller's init budget (main.go's 60s initCtx) is actually +// enforced. +// +// We deliberately do NOT call Shutdown on cancellation: rcpex's +// Shutdown takes the manager's write lock while Initialize holds the +// read lock, so Shutdown would block on (not abort) the in-flight +// cpex_initialize — it can't cancel it. The orphaned goroutine +// finishes on its own when cpex_initialize returns; its handle is +// reclaimed by the framework's later Shutdown (or the GC finalizer). +// +// TODO: switch to a context-aware Initialize if the rcpex bindings +// gain one, so a stuck init can be cancelled rather than merely +// abandoned. +func (c *cpexManager) Initialize(ctx context.Context) error { + done := make(chan error, 1) + go func() { + done <- c.mgr.Initialize() + }() + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} + +func (c *cpexManager) Shutdown(_ context.Context) { + c.mgr.Shutdown() +} + +func (c *cpexManager) HasHook(name string) bool { + return c.mgr.HasHooksFor(name) +} + +// Invoke builds a CMF payload + Extensions from pctx, calls the CPEX +// FFI, applies any extension modifications back to pctx, and maps the +// PipelineResult into our aggregate Result. +// +// Scope: +// - Identity, HTTP headers, and Direction are mapped to CPEX +// (enough for identity-driven policies — Cedar, simple allow/deny). +// - Body content is mapped as a single text part when present. +// - Header and label modifications ARE applied back to pctx when +// CPEX policy mutates the Extensions. +// - Body modifications log a WARN but don't rewrite pctx.Body — +// format-aware re-serialization (JSON-RPC, OpenAI) lands later. +// - BackgroundTasks (audit-logger and other async sub-plugins) are +// awaited in a fire-and-forget goroutine; errors land on the +// default slog handler with the request ID for correlation. +func (c *cpexManager) Invoke(_ context.Context, hookName string, pctx *pipeline.Context) (Result, error) { + // Phase is taken from the framework-set current phase, NOT inferred + // from body length: an empty-bodied response (HTTP 204, empty tool + // result) must still be treated as the response phase so response + // policy fires. CurrentPhase is "" only outside a dispatch (defensive + // — Invoke always runs inside OnRequest/OnResponse), which falls + // through to the request phase. + isResponse := pctx.CurrentPhase() == pipeline.InvocationPhaseResponse + payload, ext := buildCMF(pctx, isResponse) + + // Fused identity-resolve + hook invoke. cpex-core runs the identity + // resolvers (jwt-user / jwt-client) ONLY on the identity.resolve hook, + // never inside a tool/prompt/resource hook. An FFI host must therefore + // resolve identity and forward the principal, or per-route APL gates + // (require(role.hr), Cedar principal.roles, redact(!perm.*)) see an + // empty subject and deny everything. We use the fused InvokeResolved + // rather than a separate resolve+invoke pair so the resolved + // raw_credentials — whose inbound tokens are skip-serialized and can't + // cross the FFI boundary — reach delegate() in Rust memory for token + // exchange. + var ( + pres *rcpex.PipelineResult + ct *rcpex.ContextTable + bg *rcpex.BackgroundTasks + err error + ) + if hookName != rcpex.HookIdentityResolve && c.mgr.HasHooksFor(rcpex.HookIdentityResolve) { + idp := rcpex.NewIdentityPayload(rcpex.TokenSourceBearer, lowerHeaders(pctx.Headers)) + pres, ct, bg, err = c.mgr.InvokeResolved(idp, hookName, rcpex.PayloadCMFMessage, payload, ext, nil) + } else { + pres, ct, bg, err = c.mgr.InvokeByName(hookName, rcpex.PayloadCMFMessage, payload, ext, nil) + } + if ct != nil { + defer ct.Close() + } + if bg != nil { + // Spawn a goroutine to consume bg.Wait. Audit-logger and + // other async sub-plugins emit their work product here; if + // we Close instead of Wait, the work product is dropped. + // Goroutine carries the request-id (from X-Request-Id) so + // background errors correlate with the foreground request. + reqID := pctx.Headers.Get("X-Request-Id") + go awaitBackground(hookName, reqID, bg) + } + if err != nil { + return Result{Decision: DecisionError, Reason: err.Error()}, + fmt.Errorf("cpex invoke %q: %w", hookName, err) + } + + // Apply modifications only when the policy will allow the request + // through. On deny, the modifications are moot — nothing of the + // modified request is forwarded — so skip the work. + if pres.ContinueProcessing { + if applyErr := applyModificationsToPctx(pctx, pres, isResponse); applyErr != nil { + // A modification CPEX requested could not be applied — a + // decode failure (CPEX/pctx contract mismatch) or a body + // rewrite we have no re-serializer for. Either way the + // modified message did NOT make it onto pctx, so forwarding + // the original would silently drop the policy's intent + // (e.g. a PII redaction). Surface it as DecisionError + + // error so runHooks routes through fail_open: block when + // fail_open=false, allow-with-log when true. + slog.Warn("cpex: failed to apply modifications", + "hook", hookName, "error", applyErr) + return Result{Decision: DecisionError, Reason: applyErr.Error()}, + fmt.Errorf("cpex apply modifications %q: %w", hookName, applyErr) + } + } + + return mapResult(pres), nil +} + +// lowerHeaders flattens http.Header into the lowercase-keyed single-value +// map the identity resolvers expect (they look their configured header up +// case-folded). Unlike flattenHeaders this keeps Authorization / +// X-User-Token — the jwt resolvers need the raw tokens — and does not +// strip the audit-sensitive set, because the IdentityPayload header map +// is consumed only by the in-process resolvers, never logged. +func lowerHeaders(h http.Header) map[string]string { + if len(h) == 0 { + return nil + } + out := make(map[string]string, len(h)) + for k, vs := range h { + if len(vs) == 0 { + continue + } + if len(vs) == 1 { + out[strings.ToLower(k)] = vs[0] + } else { + out[strings.ToLower(k)] = strings.Join(vs, ", ") + } + } + return out +} + +// backgroundWaitTimeout bounds how long awaitBackground blocks on a +// single Invoke's background tasks. A stalled sink (e.g. an audit +// endpoint that never responds) would otherwise pin one goroutine per +// request indefinitely; the deadline caps the leak at one goroutine for +// at most this long. Best-effort — the result only feeds slog — so a +// generous value avoids dropping work product from merely-slow sinks. +const backgroundWaitTimeout = 30 * time.Second + +// awaitBackground blocks on bg.Wait — which returns when every +// background sub-plugin spawned by this Invoke has finished — and +// logs the per-sub-plugin error report (if any) via slog. Operators +// pipe the slog output to their audit/observability stack. +// +// Bounded by backgroundWaitTimeout: bg.Wait runs in an inner goroutine +// and we select on it vs a timer. On timeout we log a WARN and return, +// so a stalled sink can't leak goroutines without bound. The inner +// goroutine still exits whenever bg.Wait eventually returns. +// +// Concurrency notes: +// - The goroutine outlives the request; it does NOT touch pctx +// (pctx may be reused by the framework after Invoke returns). +// hook and reqID are captured by value. +// - If the manager shuts down while we're waiting, bg.Wait +// returns an error which we log at WARN. We do not attempt to +// cancel — the underlying FFI doesn't expose a cancel knob, and +// a graceful shutdown should drain background tasks anyway. +// - elapsed gives operators a knob to spot slow async work in the +// log stream (e.g. a slow audit sink). +func awaitBackground(hook, reqID string, bg *rcpex.BackgroundTasks) { + start := time.Now() + type waitResult struct { + errs []rcpex.PluginError + err error + } + done := make(chan waitResult, 1) + go func() { + errs, err := bg.Wait() + done <- waitResult{errs: errs, err: err} + }() + + timer := time.NewTimer(backgroundWaitTimeout) + defer timer.Stop() + + select { + case <-timer.C: + slog.Warn("cpex: background tasks wait timed out", + "hook", hook, "req_id", reqID, + "elapsed", time.Since(start), "timeout", backgroundWaitTimeout) + return + case res := <-done: + elapsed := time.Since(start) + if res.err != nil { + slog.Warn("cpex: background tasks wait failed", + "hook", hook, "req_id", reqID, "elapsed", elapsed, "error", res.err) + return + } + for _, e := range res.errs { + slog.Warn("cpex: background sub-plugin error", + "hook", hook, "req_id", reqID, "elapsed", elapsed, + "plugin", e.PluginName, "code", e.Code, "message", e.Message) + } + } +} + +// applyModificationsToPctx writes CPEX's modified Extensions and body +// back onto pctx. MCP body modifications are re-serialized; inference / +// A2A body rewriting is not yet implemented and fails closed (see +// applyBodyModFromCMF). +// +// Extension changes applied: +// +// - HttpExtension.RequestHeaders → pctx.Headers +// Set the headers CPEX listed; preserve any pctx headers CPEX +// didn't model (multi-value, e.g. Set-Cookie). +// - SecurityExtension.Labels → pctx.Extensions.Security.Labels +// Merge-add (no duplicates). Existing labels stay; new ones +// append. Operators reading session events see the union. +func applyModificationsToPctx(pctx *pipeline.Context, pres *rcpex.PipelineResult, isResponse bool) error { + if len(pres.ModifiedExtensions) > 0 { + ext, err := pres.DeserializeExtensions() + if err != nil { + return fmt.Errorf("decode modified extensions: %w", err) + } + if ext != nil { + applyExtensionChanges(pctx, ext) + } + } + + if len(pres.ModifiedPayload) > 0 { + payload, err := rcpex.DeserializePayload[rcpex.MessagePayload](pres) + if err != nil { + return fmt.Errorf("decode modified payload: %w", err) + } + if payload != nil { + if err := applyBodyModFromCMF(pctx, &payload.Message, isResponse); err != nil { + return fmt.Errorf("apply body mod: %w", err) + } + } + } + + return nil +} + +// applyBodyModFromCMF dispatches the body-rewriting logic per format +// detected from pctx.Extensions. isResponse chooses between request and +// response body. MCP, inference (OpenAI chat/completions), and A2A +// (JSON-RPC) all have format-aware re-serializers; the redacted text the +// policy returned is lifted off the CMF Message's text parts (in document +// order) and spliced back into the original envelope. +// +// Fail-closed contract: CPEX requested a body modification (e.g. a PII +// redaction). When a re-serializer returns an error — a decode failure, a +// part-count drift between what we sent and what came back, or a +// non-rewritable shape such as an SSE stream — we propagate it rather than +// silently forwarding the ORIGINAL body. The caller (Invoke) surfaces that +// as a DecisionError that fail_open governs, so an unappliable redaction +// blocks (fail_open=false) instead of leaking the unredacted body +// downstream. A re-serializer reporting mutated=false with no error is a +// legitimate no-op (nothing matched the redaction target) and proceeds. +func applyBodyModFromCMF(pctx *pipeline.Context, msg *rcpex.Message, isResponse bool) error { + switch { + case pctx.Extensions.MCP != nil: + return applyMCPBodyModFromCMF(pctx, msg, isResponse) + + case pctx.Extensions.Inference != nil: + texts := textParts(msg) + if isResponse { + // Single completion: rewrite choices[].message.content from the + // first (and normally only) redacted text part. + _, err := applyInferenceResponseBodyMod(pctx, firstText(texts)) + return err + } + _, err := applyInferenceRequestBodyMod(pctx, texts) + return err + + case pctx.Extensions.A2A != nil: + texts := textParts(msg) + if isResponse { + _, err := applyA2AResponseBodyMod(pctx, firstText(texts)) + return err + } + _, err := applyA2ARequestBodyMod(pctx, texts) + return err + + default: + // No protocol parser claimed this traffic, so we have no + // format-aware re-serializer for whatever opaque body crossed. + // Fail closed: a requested redaction we can't apply must not be + // dropped silently. + slog.Warn("cpex: body modification requested but no protocol extension present — failing closed", + "path", pctx.Path) + return fmt.Errorf("body modification requested but no protocol parser claimed this traffic") + } +} + +// textParts collects the Text of every text-kind content part in a CMF +// Message, in document order. This is how the cgo adapter recovers the +// redacted strings a CPEX policy produced (PII scanners edit text parts in +// place) to hand to the tag-free inference / A2A re-serializers. +func textParts(msg *rcpex.Message) []string { + var out []string + for _, p := range msg.Content { + if p.ContentType == rcpex.ContentTypeText { + out = append(out, p.Text) + } + } + return out +} + +// firstText returns the first element of texts, or "" when empty. Used for +// the single-completion response re-serializers (inference completion, A2A +// artifact) which take one string rather than a positional list. +func firstText(texts []string) string { + if len(texts) == 0 { + return "" + } + return texts[0] +} + +// applyMCPBodyModFromCMF translates a CMF Message into the fields +// applyMCPRequestBodyMod / applyMCPResponseBodyMod expect, then +// dispatches based on the request-vs-response phase. +// +// Request side picks the first ToolCall / PromptRequest / +// ResourceReference content part — these are mutually exclusive in +// practice (a single tool call has one shape). +// +// Response side picks the first ToolResult content part; its Content +// field carries the new payload. +// +// Phase is supplied by the caller from pctx.CurrentPhase(), NOT inferred +// from body length (an empty-bodied response must still take the response +// path), NOT pctx.Direction (a reverse-proxy pctx stays Direction=Inbound +// for both phases), and NOT mcp.Result (mcp-parser runs AFTER cpex on the +// response, so Result isn't populated yet when we apply). +func applyMCPBodyModFromCMF(pctx *pipeline.Context, msg *rcpex.Message, isResponse bool) error { + method := pctx.Extensions.MCP.Method + if !isResponse { + mod := MCPRequestBodyMod{} + for _, part := range msg.Content { + switch part.ContentType { + case "tool_call": + if part.ToolCallContent != nil { + mod.NewArguments = part.ToolCallContent.Arguments + } + case "prompt_request": + if part.PromptRequestContent != nil { + mod.NewArguments = part.PromptRequestContent.Arguments + } + case "resource_ref": + if part.ResourceRefContent != nil { + mod.NewURI = part.ResourceRefContent.URI + } + default: + continue + } + break + } + if _, err := applyMCPRequestBodyMod(pctx, method, mod); err != nil { + return err + } + return nil + } + // Response side. + for _, part := range msg.Content { + if part.ContentType == "tool_result" && part.ToolResultContent != nil { + if _, err := applyMCPResponseBodyMod(pctx, method, part.ToolResultContent.Content); err != nil { + return err + } + break + } + } + return nil +} + +// applyExtensionChanges mutates pctx based on a CPEX-modified +// Extensions struct. Field-by-field, with deliberate ownership notes: +// +// - Headers: replace each modified key in pctx.Headers. Keys CPEX +// didn't include stay untouched — this is merge-replace, not +// wholesale-replace, because CPEX doesn't see (and can't reason +// about) headers the operator explicitly hid via flattenHeaders +// (Authorization, Cookie). A wholesale replace would strip those. +// +// - Labels: merge-add. CPEX labels join the pctx label set; we +// don't remove labels other plugins set. +func applyExtensionChanges(pctx *pipeline.Context, ext *rcpex.Extensions) { + if ext.Http != nil && len(ext.Http.RequestHeaders) > 0 { + if pctx.Headers == nil { + pctx.Headers = http.Header{} + } + for k, v := range ext.Http.RequestHeaders { + pctx.Headers.Set(k, v) + } + } + + if ext.Security != nil && len(ext.Security.Labels) > 0 { + if pctx.Extensions.Security == nil { + pctx.Extensions.Security = &pipeline.SecurityExtension{} + } + existing := make(map[string]struct{}, len(pctx.Extensions.Security.Labels)) + for _, l := range pctx.Extensions.Security.Labels { + existing[l] = struct{}{} + } + for _, l := range ext.Security.Labels { + if _, ok := existing[l]; !ok { + pctx.Extensions.Security.Labels = append(pctx.Extensions.Security.Labels, l) + existing[l] = struct{}{} + } + } + } +} + +// buildCMF builds a CMF MessagePayload + Extensions from a +// pipeline.Context. The phase (request vs response) is supplied by the +// caller from pctx.CurrentPhase() rather than inferred here. +// +// Content mapping is delegated to the tag-free selector pctxToCMFParts +// (MCP → inference → A2A → opaque text), which also yields the entity +// routing coordinates. The extension mapping below brings the CPEX policy +// input to parity with the OPA plugin: +// +// content → structured CMF parts per protocol (pctxToCMFParts) +// entity → MetaExtension.{EntityType,EntityName} for route dispatch +// identity → SecurityExtension.Subject{ID, Roles} + AuthMethod + +// curated Claims (issuer/audience/exp) via ClaimsCarrier +// agent → SecurityExtension.Agent (workload client_id / SPIFFE id / +// trust domain) — the agent's OWN identity, distinct from +// the caller in Subject +// inference → LLMExtension (model/provider), CompletionExtension +// (stop reason + token usage) on response, and the request +// conversation as AgentExtension.Conversation.History +// a2a → AgentExtension.{SessionID, ConversationID} + +// ProvenanceExtension.MessageID +// delegation → DelegationExtension (chain/origin/actor/depth) +// request → RequestExtension (request id + trace/span ids) +// headers → HttpExtension.RequestHeaders (Authorization and Cookie +// stripped — they don't belong in policy context and would +// leak through CPEX traces) +// +// Note (agent slot): the caller's client_id is NOT placed on +// AgentExtension.AgentID. Caller identity lives in SecurityExtension.Subject; +// AgentExtension models the agent's own execution/session context. This is +// the spec-correct split (matches OPA + the CMF spec) and means policies +// must read caller identity from security.subject, not agent.agent_id. +func buildCMF(pctx *pipeline.Context, isResponse bool) (rcpex.MessagePayload, *rcpex.Extensions) { + // Phase drives the CMF role. Per-message roles (for multi-turn + // inference) are carried separately in AgentExtension.Conversation. + role := "user" + if isResponse { + role = "assistant" + } + + cmfParts, entityType, entityName := pctxToCMFParts(pctx, isResponse) + var parts []rcpex.ContentPart + for _, cp := range cmfParts { + parts = append(parts, cmfPartToContentParts(cp)...) + } + payload := rcpex.MessagePayload{Message: rcpex.NewMessage(role, parts...)} + + ext := &rcpex.Extensions{} + + // Stamp the entity coordinates so cpex-core's route resolver + // (filter_entries_by_route) dispatches the per-entity APL policy + // handlers — require/Cedar/delegate/field-redaction. Without meta, + // only always-on global plugins fire and the route's deny gates are + // silently skipped. Entity is per-tool for MCP, per-model for + // inference, per-method for A2A (see pctxToCMFParts). + if entityType != "" && entityName != "" { + ext.Meta = &rcpex.MetaExtension{EntityType: entityType, EntityName: entityName} + } + + // Identity → SecurityExtension.Subject. Subject id + scopes→roles + // always; auth method + curated claims when the identity implements + // the richer ClaimsCarrier capability (jwt-validation does). + if id := pctx.Identity; id != nil { + ext.Security = &rcpex.SecurityExtension{ + Subject: &rcpex.SubjectExtension{ + ID: id.Subject(), + Roles: id.Scopes(), + }, + } + if cc, ok := id.(contracts.ClaimsCarrier); ok { + ext.Security.AuthMethod = cc.AuthMethod() + if claims := cc.Claims(); len(claims) > 0 { + ext.Security.Subject.Claims = claims + } + } + } + + // Agent workload identity → SecurityExtension.Agent. This is the + // agent's OWN identity (its Keycloak client / SPIFFE id), not the + // caller's — kept distinct from Subject above. + if a := pctx.Agent; a != nil && (a.ClientID != "" || a.WorkloadID != "" || a.TrustDomain != "") { + if ext.Security == nil { + ext.Security = &rcpex.SecurityExtension{} + } + ext.Security.Agent = &rcpex.AgentIdentity{ + ClientID: a.ClientID, + WorkloadID: a.WorkloadID, + TrustDomain: a.TrustDomain, + } + } + + // Inference metadata → LLM / Completion / Agent.Conversation. + if inf := pctx.Extensions.Inference; inf != nil { + if inf.Model != "" { + ext.LLM = &rcpex.LLMExtension{ModelID: inf.Model, Provider: inferProvider(inf.Model)} + } + if isResponse { + // Completion fields are populated by inference-parser's + // OnResponse. Like the response content parts they may not be + // set yet when cpex runs first on the reverse pass; we map + // whatever is present (a later-running consumer still sees the + // parser's full record on the session event). + comp := &rcpex.CompletionExtension{StopReason: inf.FinishReason, Model: inf.Model} + if inf.PromptTokens > 0 || inf.CompletionTokens > 0 || inf.TotalTokens > 0 { + comp.Tokens = &rcpex.TokenUsage{ + InputTokens: inf.PromptTokens, + OutputTokens: inf.CompletionTokens, + TotalTokens: inf.TotalTokens, + } + } + ext.Completion = comp + } else if hist := inferenceHistory(inf); len(hist) > 0 { + ext.Agent = ensureAgentExt(ext.Agent) + ext.Agent.Conversation = &rcpex.ConversationContext{History: toAnySlice(hist)} + } + } + + // A2A metadata → Agent session/conversation + Provenance. + if a2a := pctx.Extensions.A2A; a2a != nil { + if a2a.SessionID != "" || a2a.TaskID != "" { + ext.Agent = ensureAgentExt(ext.Agent) + ext.Agent.SessionID = a2a.SessionID + ext.Agent.ConversationID = a2a.TaskID + } + if a2a.MessageID != "" { + ext.Provenance = &rcpex.ProvenanceExtension{Source: "a2a", MessageID: a2a.MessageID} + } + } + + // Session id for cross-request CPEX state (taint/label persistence). + // A2A carries it natively (above); other protocols (MCP, inference) + // can supply it via X-Session-Id, which CPEX's session resolver reads + // off Agent.SessionID (tier-0). A2A keeps precedence — only fill from + // the header when no session id is set yet. + if sid := sessionIDFromHeaders(pctx.Headers); sid != "" { + ext.Agent = ensureAgentExt(ext.Agent) + if ext.Agent.SessionID == "" { + ext.Agent.SessionID = sid + } + } + + // Delegation chain → DelegationExtension (populated by token-exchange). + if d := mapDelegation(pctx.Extensions.Delegation); d != nil { + ext.Delegation = d + } + + // Request id / trace headers → RequestExtension (best-effort). + if r := requestExtFromHeaders(pctx.Headers); r != nil { + ext.Request = r + } + + if len(pctx.Headers) > 0 { + ext.Http = &rcpex.HttpExtension{ + RequestHeaders: flattenHeaders(pctx.Headers), + } + } + + // Custom-extension passthrough: forward only entries operators + // explicitly scoped under "cpex/" — everything else (rate-limiter + // cookies, plugin-private state) stays on the AuthBridge side. + // Operators stash policy-input blobs in pctx.Extensions.Custom + // under "cpex/" and CPEX sub-plugins read them at . + if pctx.Extensions.Custom != nil { + var picked map[string]any + for k, v := range pctx.Extensions.Custom { + if !strings.HasPrefix(k, "cpex/") { + continue + } + if picked == nil { + picked = map[string]any{} + } + picked[strings.TrimPrefix(k, "cpex/")] = v + } + if picked != nil { + ext.Custom = picked + } + } + + return payload, ext +} + +// ensureAgentExt returns a usable *AgentExtension — the existing one or a +// freshly allocated empty one. Lets the inference and A2A mappings each +// populate fields on a shared AgentExtension without racing on who +// allocates it. +func ensureAgentExt(a *rcpex.AgentExtension) *rcpex.AgentExtension { + if a == nil { + return &rcpex.AgentExtension{} + } + return a +} + +// toAnySlice widens a []map[string]any to the []any that +// ConversationContext.History expects (Rust's Vec). +func toAnySlice(in []map[string]any) []any { + out := make([]any, len(in)) + for i, m := range in { + out[i] = m + } + return out +} + +// mapDelegation converts AuthBridge's DelegationExtension into the rcpex +// shape. Returns nil for a delegation slot that carries no chain and no +// origin/actor (so an empty extension doesn't add a hollow Delegation +// block to policy input). Depth/Delegated are derived from the chain. +// A zero hop timestamp is left empty rather than rendered as the Go zero +// time, so policies don't see a spurious "0001-01-01" age. +func mapDelegation(d *pipeline.DelegationExtension) *rcpex.DelegationExtension { + if d == nil { + return nil + } + hops := d.Chain() + if len(hops) == 0 && d.Origin == "" && d.Actor == "" { + return nil + } + out := &rcpex.DelegationExtension{ + OriginSubjectID: d.Origin, + ActorSubjectID: d.Actor, + Depth: d.Depth(), + Delegated: d.Depth() > 0, + } + for _, h := range hops { + rh := rcpex.DelegationHop{ + SubjectID: h.SubjectID, + Audience: h.Audience, + ScopesGranted: h.Scopes, + Strategy: h.Strategy, + FromCache: h.FromCache, + } + if !h.Timestamp.IsZero() { + rh.Timestamp = h.Timestamp.UTC().Format(time.RFC3339) + } + out.Chain = append(out.Chain, rh) + } + return out +} + +// requestExtFromHeaders extracts request-correlation ids from common +// tracing headers into a RequestExtension. Best-effort and low-cost: +// X-Request-Id for the request id, B3 headers or W3C traceparent for the +// trace/span ids. Returns nil when none are present so an untraced request +// doesn't carry an empty Request block. +func requestExtFromHeaders(h http.Header) *rcpex.RequestExtension { + if len(h) == 0 { + return nil + } + reqID := h.Get("X-Request-Id") + traceID := h.Get("X-B3-Traceid") + spanID := h.Get("X-B3-Spanid") + if traceID == "" { + // W3C traceparent: "version-traceid-spanid-flags". + if fields := strings.Split(h.Get("traceparent"), "-"); len(fields) >= 3 { + traceID = fields[1] + if spanID == "" { + spanID = fields[2] + } + } + } + if reqID == "" && traceID == "" && spanID == "" { + return nil + } + return &rcpex.RequestExtension{RequestID: reqID, TraceID: traceID, SpanID: spanID} +} + +// cmfPartToContentParts converts the protocol-neutral cmfPart (decided by +// the tag-free selector pctxToCMFParts) into the rcpex content part CPEX +// dispatches on. Splitting the decision (tag-free, unit-tested) from this +// rcpex conversion (cgo-only) keeps the protocol→CMF mapping testable +// without the FFI, mirroring the cmf_body.go / applyMCPBodyModFromCMF +// split. +// +// The structured part carries the tool args/result CPEX policies read and +// rewrite; route *selection* is driven separately by Extensions.Meta (set +// in buildCMF from the selector's entity coords). +func cmfPartToContentParts(part cmfPart) []rcpex.ContentPart { + switch part.Kind { + case cmfPartToolCall: + return []rcpex.ContentPart{rcpex.NewToolCallPart(rcpex.ToolCall{ + ToolCallID: part.CorrelationID, + Name: part.Name, + Arguments: part.Arguments, + })} + case cmfPartPromptRequest: + return []rcpex.ContentPart{rcpex.NewPromptRequestPart(rcpex.PromptRequest{ + PromptRequestID: part.CorrelationID, + Name: part.Name, + Arguments: part.Arguments, + })} + case cmfPartResourceRef: + return []rcpex.ContentPart{rcpex.NewResourceRefPart(rcpex.ResourceReference{ + ResourceRequestID: part.CorrelationID, + URI: part.URI, + ResourceType: "uri", + })} + case cmfPartToolResult: + return []rcpex.ContentPart{rcpex.NewToolResultPart(rcpex.ToolResult{ + ToolCallID: part.CorrelationID, + ToolName: part.Name, + Content: part.Content, + IsError: part.IsError, + })} + default: // cmfPartText + if part.Text == "" { + return nil + } + return []rcpex.ContentPart{rcpex.NewTextPart(part.Text)} + } +} + +// (flattenHeaders, isSensitive, and the secretHeader* tables live in +// the tag-free headers.go so they're testable under the default +// CGO_ENABLED=0 build.) + +// mapResult collapses a CPEX PipelineResult into our aggregate +// Decision/Result. Order matters: deny first, then modify, then allow +// — a single result can carry a violation AND modifications, and the +// violation wins. +func mapResult(p *rcpex.PipelineResult) Result { + res := Result{} + + for _, e := range p.Errors { + res.Errors = append(res.Errors, SubPluginError{ + Plugin: e.PluginName, + Code: e.Code, + Message: e.Message, + }) + if e.PluginName != "" { + res.PluginsRun = append(res.PluginsRun, e.PluginName) + } + } + + if !p.ContinueProcessing { + res.Decision = DecisionDeny + if p.Violation != nil { + res.Code = p.Violation.Code + res.Reason = p.Violation.Reason + if p.Violation.PluginName != "" { + res.PluginsRun = appendUnique(res.PluginsRun, p.Violation.PluginName) + } + } else { + res.Reason = "policy denied" + } + return res + } + + if len(p.ModifiedPayload) > 0 || len(p.ModifiedExtensions) > 0 { + // Extension changes (headers, labels) and MCP body changes have + // already been applied to pctx by applyModificationsToPctx + // before mapResult runs. Report modify so the Invocation + // reflects that policy touched the message. + res.Decision = DecisionModify + switch { + case len(p.ModifiedPayload) > 0 && len(p.ModifiedExtensions) > 0: + res.Reason = "policy modified headers/labels and body" + case len(p.ModifiedPayload) > 0: + res.Reason = "policy modified body" + default: + res.Reason = "policy modified headers/labels" + } + return res + } + + res.Decision = DecisionAllow + res.Reason = "all policies passed" + return res +} + +func appendUnique(s []string, v string) []string { + for _, x := range s { + if x == v { + return s + } + } + return append(s, v) +} diff --git a/authbridge/authlib/plugins/cpex/manager_stub.go b/authbridge/authlib/plugins/cpex/manager_stub.go new file mode 100644 index 000000000..46df27ba9 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager_stub.go @@ -0,0 +1,25 @@ +//go:build !cpex + +package cpex + +import "errors" + +// errNoCpexBuild is what NewManager returns when the binary was +// compiled without -tags cpex. The plugin's Configure surfaces this +// error to the operator at boot, so they don't get a silent +// no-op cpex plugin in production. +// +// Operators hitting this should switch to the authbridge-cpex image +// (or rebuild their custom binary with `-tags cpex`). +var errNoCpexBuild = errors.New("cpex plugin: this binary was not built with -tags cpex; use the authbridge-cpex image, or rebuild with -tags cpex") + +// NewManager (stub) always returns errNoCpexBuild. The real +// implementation lives in manager_cpex.go (//go:build cpex). +// +// Returning an error here — rather than panicking or registering a +// silent no-op — preserves the fail-loud guarantee from the design +// doc: if you configured the cpex plugin in YAML, you want a +// pipeline build failure if the binary can't honor it. +func NewManager(_ ManagerOptions) (Manager, error) { + return nil, errNoCpexBuild +} diff --git a/authbridge/authlib/plugins/cpex/manager_test.go b/authbridge/authlib/plugins/cpex/manager_test.go new file mode 100644 index 000000000..163c53529 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager_test.go @@ -0,0 +1,122 @@ +package cpex + +import ( + "context" + "sync" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// FakeManager is a programmable Manager for unit tests. Set Hooks, +// InvokeErr, etc. before the test action; read LoadedYAML, +// Initialized, ShutdownCalled, Invokes after. +// +// Zero-value FakeManager is ready to use. Concurrent Invoke calls +// are safe. +// +// The fake lives outside _test.go so future integration suites in +// cmd/authbridge-cpex/ or downstream tools can reuse it. The cost is +// that it ships in the authbridge-cpex binary; no caller path +// instantiates it from production code, so the risk is just dead +// bytes. If binary size becomes a concern this moves to +// fake_manager_test.go and stays in-package. +type FakeManager struct { + mu sync.Mutex + + // Hooks maps hook name → canned Result. Invoke returns the + // canned Result when the hook name matches; otherwise it + // returns DecisionAllow with a "no hook configured" reason. + Hooks map[string]Result + + // InvokeErr, if non-nil, is returned from every Invoke before + // the Hooks lookup. Use for fail_open tests. + InvokeErr error + + // LoadErr / InitErr are returned from LoadConfig and Initialize. + LoadErr error + InitErr error + + // KnownHooks overrides HasHook's reply. When nil/empty, HasHook + // falls back to the keyset of Hooks. Set explicitly when you + // want HasHook to report a hook that isn't in Hooks (covers the + // "Invoke is dispatched but returns Allow" path). + KnownHooks []string + + // Recorded state — read after the test action. + LoadedYAML string + Initialized bool + ShutdownCalled bool + Invokes []FakeInvoke +} + +// FakeInvoke records one Invoke call on a FakeManager. +type FakeInvoke struct { + Hook string + // Pctx is the pipeline.Context the plugin built and handed us. + // Inspect after the test to verify the right inputs were passed + // (identity, headers, body, classification). + Pctx *pipeline.Context +} + +// Static interface assertion: FakeManager satisfies Manager. If +// Manager gains a method, this line forces FakeManager to be +// updated in the same commit (compile error in default tests). +var _ Manager = (*FakeManager)(nil) + +// LoadConfig records yaml and returns LoadErr. +func (f *FakeManager) LoadConfig(yaml string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.LoadedYAML = yaml + return f.LoadErr +} + +// Initialize records the call and returns InitErr (if set). +func (f *FakeManager) Initialize(_ context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.InitErr != nil { + return f.InitErr + } + f.Initialized = true + return nil +} + +// Shutdown records the call. +func (f *FakeManager) Shutdown(_ context.Context) { + f.mu.Lock() + defer f.mu.Unlock() + f.ShutdownCalled = true +} + +// HasHook returns whether the hook is reportedly known. KnownHooks +// takes precedence over the Hooks map. +func (f *FakeManager) HasHook(name string) bool { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.KnownHooks) > 0 { + for _, h := range f.KnownHooks { + if h == name { + return true + } + } + return false + } + _, ok := f.Hooks[name] + return ok +} + +// Invoke records the call and returns InvokeErr (if set), the canned +// Result for hookName (if in Hooks), or a default DecisionAllow. +func (f *FakeManager) Invoke(_ context.Context, hookName string, pctx *pipeline.Context) (Result, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.Invokes = append(f.Invokes, FakeInvoke{Hook: hookName, Pctx: pctx}) + if f.InvokeErr != nil { + return Result{}, f.InvokeErr + } + if r, ok := f.Hooks[hookName]; ok { + return r, nil + } + return Result{Decision: DecisionAllow, Reason: "fake: no hook configured"}, nil +} diff --git a/authbridge/authlib/plugins/cpex/plugin.go b/authbridge/authlib/plugins/cpex/plugin.go new file mode 100644 index 000000000..488f2bbae --- /dev/null +++ b/authbridge/authlib/plugins/cpex/plugin.go @@ -0,0 +1,457 @@ +// Package cpex bridges AuthBridge requests through the CPEX (Context +// Plugin Execution) framework so operators can drive policy with +// CPEX's APL (Attribute Policy Language) DSL — or any of the +// pre-built CPEX plugins (Cedar PDP, PII scanner, audit logger, +// JWT identity, OAuth delegation) — without writing Go code. +// +// One AuthBridge plugin instance ("cpex") wraps an entire CPEX +// PluginManager. Operators declare which CPEX hook names fire on +// each AuthBridge phase via the cpex config's `hooks` block: +// +// plugins: +// - name: cpex +// config: +// hooks: +// on_request: [cmf.tool_pre_invoke] +// on_response: [cmf.tool_post_invoke] +// apl: { ... operator's APL block ... } +// pipelines: { ... operator's CPEX pipelines block ... } +// +// Hooks fire in declaration order; the chain short-circuits on the +// first sub-plugin that returns deny. Empty hook lists are valid (the +// phase becomes a no-op) so operators can install the plugin and ship +// a YAML update later to enable it. +// +// # Build tag +// +// The real CPEX backend uses cgo and links libcpex_ffi.a. The +// authbridge-cpex binary is the only build target that compiles +// this package with -tags cpex; other binaries (authbridge-proxy, +// authbridge-envoy, authbridge-lite) do not import this package. +// +// The package itself compiles tag-free for unit tests: the Manager +// interface (manager.go) is satisfied by FakeManager in tests, and +// the real adapter (manager_cpex.go) is gated by //go:build cpex. +// `go test ./authlib/plugins/cpex/...` with CGO_ENABLED=0 covers +// every behavior except the FFI translation itself. +// +// Without -tags cpex, NewManager returns an error (manager_stub.go) +// so Configure fails loud at boot rather than silently registering +// a no-op cpex plugin in production. +// +// # Operator surface +// +// Plugin name: `cpex`. Config schema is in config.go and is +// surfaced through pipeline.SchemaProvider for abctl and friends. +package cpex + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strings" + "sync/atomic" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" +) + +// CPEX is the AuthBridge plugin chassis around a CPEX PluginManager. +// Per-instance state: parsed config, the Manager adapter (Fake in +// tests, real cpex.PluginManager in the cpex binary), and a ready +// flag set after Init. +type CPEX struct { + cfg cpexConfig + manager Manager + ready atomic.Bool + + // bypassPaths matches URL paths that skip CPEX entirely (no FFI + // crossing, no policy evaluation). Built from cfg.BypassPaths in + // Configure using authlib's shared bypass.Matcher so semantics + // match jwt-validation and other plugins. + bypassPaths *bypass.Matcher + + // bypassHosts is the resolved host glob list. Lower-cost than a + // matcher because we only compare against a single host per + // request; the goroutine-safe scan happens via matchesAnyHost. + bypassHosts []string + + // newManager is the Manager constructor used by Configure. When + // nil — the production default — Configure calls the + // package-level NewManager (real cpex adapter when -tags cpex, + // errNoCpexBuild otherwise). Tests inject a closure here to + // return a FakeManager and exercise Configure's full path. + newManager func(ManagerOptions) (Manager, error) +} + +// NewCPEX returns an unconfigured plugin. The registry calls this on +// every pipeline build; Configure populates the rest. +func NewCPEX() *CPEX { return &CPEX{} } + +func init() { + plugins.RegisterPlugin("cpex", func() pipeline.Plugin { return NewCPEX() }) +} + +// Name is the registry key operators reference in YAML. +func (p *CPEX) Name() string { return "cpex" } + +// Capabilities declares body access and content-source requirements. +// +// - ReadsBody / WritesBody: CPEX policies routinely inspect and +// mutate tool args, LLM messages, and HTTP headers, so the +// plugin needs the body buffered and writable. (Normalize() +// auto-promotes ReadsBody from WritesBody, so this is belt and +// suspenders.) +// +// - RequiresAny: the plugin reads through pctx.ContentSources() +// in the CMF adapter (cmf.go), so at least one parser must +// populate them. Failing fast at pipeline.Build is better than +// silently running CPEX policies over empty content. +// +// - Description is the one-line operator-facing summary abctl +// surfaces in the catalog. +func (p *CPEX) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{ + ReadsBody: true, + WritesBody: true, + RequiresAny: []string{"mcp-parser", "inference-parser", "a2a-parser"}, + Description: "CPEX bridge: APL DSL + named CPEX plugins (Cedar, PII, audit, …) over a single chain step.", + } +} + +// ConfigSchema reflects cpexConfig field metadata for abctl edit +// templates and JSON-Schema generators. +func (p *CPEX) ConfigSchema() []pipeline.FieldSchema { + return pipeline.SchemaOf(cpexConfig{}) +} + +// Configure decodes the plugin's config subtree, builds the Manager, +// re-serializes the operator's apl/pipelines blocks into YAML, and +// hands the result to LoadConfig. Initialize is deferred to Init so +// JWKS-fetching / audit-sink-connecting policies don't slow +// pipeline.Build. +// +// Strict decode (DisallowUnknownFields) rejects misspelled keys +// loudly at boot — operators editing the cpex block in YAML get an +// immediate "unknown field" error rather than silent partial config. +func (p *CPEX) Configure(raw json.RawMessage) error { + var c cpexConfig + if len(raw) > 0 { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&c); err != nil { + return fmt.Errorf("cpex config: %w", err) + } + } + c.applyDefaults() + if err := c.validate(); err != nil { + return fmt.Errorf("cpex config: %w", err) + } + + // Build the bypass matcher. config.applyDefaults leaves + // c.BypassPaths nil so this layer can substitute the + // canonical authlib default set (kept in one place across the + // repo by the bypass package). + bypassPaths := c.BypassPaths + if bypassPaths == nil { + bypassPaths = bypass.DefaultPatterns + } + matcher, err := bypass.NewMatcher(bypassPaths) + if err != nil { + return fmt.Errorf("cpex bypass_paths: %w", err) + } + p.bypassPaths = matcher + p.bypassHosts = c.BypassHosts + + factory := p.newManager + if factory == nil { + factory = NewManager + } + mgr, err := factory(ManagerOptions{WorkerThreads: c.WorkerThreads}) + if err != nil { + // Without -tags cpex, manager_stub.go's NewManager surfaces + // here with a clear "wrong binary" message. Pipeline.Build + // aborts and operators see it in `kubectl logs`. + return fmt.Errorf("cpex: %w", err) + } + + cfgYAML, err := c.resolveYAML() + if err != nil { + return fmt.Errorf("cpex config: %w", err) + } + if cfgYAML != "" { + if err := mgr.LoadConfig(cfgYAML); err != nil { + return fmt.Errorf("cpex: load config: %w", err) + } + } + + p.cfg = c + p.manager = mgr + return nil +} + +// (CPEX YAML resolution lives in cpexConfig.resolveYAML — handles +// both inline `config:` and path-based `config_file:`. The result is +// what we hand to Manager.LoadConfig verbatim.) + +// Init starts the CPEX runtime (binds factories to hooks, spawns +// background workers). The ctx carries pipeline.Start's init +// budget — Initialize must respect it. +func (p *CPEX) Init(ctx context.Context) error { + if p.manager == nil { + return errors.New("cpex Init: manager nil; Configure not called or failed") + } + if err := p.manager.Initialize(ctx); err != nil { + return fmt.Errorf("cpex initialize: %w", err) + } + p.ready.Store(true) + return nil +} + +// Shutdown drains in-flight work and releases CPEX resources. Best +// effort: errors are logged by the framework but don't block other +// plugins' Shutdown. +func (p *CPEX) Shutdown(ctx context.Context) error { + if p.manager == nil { + return nil + } + p.manager.Shutdown(ctx) + p.ready.Store(false) + return nil +} + +// Ready reports whether Init has completed. The framework ANDs this +// across every Readier-implementing plugin to gate /readyz. +func (p *CPEX) Ready() bool { + return p.ready.Load() +} + +// Compile-time interface assertions. If any of these break, a +// refactor downstream broke an interface our chassis silently relied +// on; we'd rather hear about it at `go build` than at pipeline start. +var ( + _ pipeline.Plugin = (*CPEX)(nil) + _ pipeline.Configurable = (*CPEX)(nil) + _ pipeline.Initializer = (*CPEX)(nil) + _ pipeline.Shutdowner = (*CPEX)(nil) + _ pipeline.Readier = (*CPEX)(nil) +) + +// OnRequest fires every CPEX hook listed in cfg.Hooks.OnRequest, in +// order. Short-circuits on the first sub-plugin that returns deny. +// Empty hook list → immediate Continue (no FFI crossing). +func (p *CPEX) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { + return p.runHooks(ctx, pctx, p.cfg.Hooks.OnRequest) +} + +// OnResponse fires every CPEX hook listed in cfg.Hooks.OnResponse, in +// order. Same short-circuit + bypass semantics as OnRequest. +func (p *CPEX) OnResponse(ctx context.Context, pctx *pipeline.Context) pipeline.Action { + return p.runHooks(ctx, pctx, p.cfg.Hooks.OnResponse) +} + +// runHooks is the shared OnRequest/OnResponse path: bypass gates, +// iterate over the operator-configured hook list, invoke each on +// CPEX, short-circuit on the first deny. +// +// Skip rules (Continue without invoking CPEX): +// - manager is nil / ready is false — pipeline misconfigured; +// defensive no-op (Pipeline.Build should have already failed). +// - len(hooks) == 0 — operator left this phase empty. +// - path or host matches the bypass lists. +// - manager.HasHook(hook) is false — operator listed a hook in +// config but no sub-plugin is wired for it on the CPEX side. +// Logged once per request via Invocation Skip rather than failing +// loudly: the same hook list may apply to multiple operator- +// configured plugin variants. +// +// Error handling honors cfg.FailOpen: +// - FailOpen=true → Observe + Continue, error logged. +// - FailOpen=false → DenyAndRecord with code "cpex.error". +func (p *CPEX) runHooks(ctx context.Context, pctx *pipeline.Context, hooks []string) pipeline.Action { + if p.manager == nil || !p.ready.Load() { + return pipeline.Action{Type: pipeline.Continue} + } + if len(hooks) == 0 { + return pipeline.Action{Type: pipeline.Continue} + } + + // Fast-skip bypass: traffic to infrastructure (Keycloak, SPIRE, + // liveness probes) shouldn't pay the FFI round-trip. Check paths + // first since most bypass traffic is health/discovery rather + // than hostname-based. + if p.bypassPaths != nil && p.bypassPaths.Match(pctx.Path) { + pctx.Skip("path_bypass") + return pipeline.Action{Type: pipeline.Continue} + } + if matchesAnyHost(p.bypassHosts, pctx.Host) { + pctx.Skip("host_bypass") + return pipeline.Action{Type: pipeline.Continue} + } + + for _, hook := range hooks { + if !p.manager.HasHook(hook) { + pctx.Skip(fmt.Sprintf("no cpex sub-plugin wired for %s", hook)) + continue + } + res, err := p.manager.Invoke(ctx, hook, pctx) + if err != nil { + return p.handleInvokeError(pctx, hook, err) + } + if action, stop := p.applyDecision(pctx, res); stop { + return action + } + } + return pipeline.Action{Type: pipeline.Continue} +} + +// handleInvokeError converts a CPEX internal error (FFI failure, +// runtime panic) into a pipeline action per cfg.FailOpen. +func (p *CPEX) handleInvokeError(pctx *pipeline.Context, hook string, err error) pipeline.Action { + if p.cfg.FailOpen { + slog.Warn("cpex: invoke errored; allowing per fail_open=true", + "hook", hook, "error", err) + pctx.Observe(fmt.Sprintf("cpex error (fail_open): %v", err)) + return pipeline.Action{Type: pipeline.Continue} + } + // A CPEX-internal failure (FFI error, runtime panic) is an upstream + // fault, not a policy decision, so reject with 502. Status is set + // explicitly for the same reason as the deny path: cpex.error is not + // in the listener's static codeToStatus table. + return denyWithStatus(pctx, http.StatusBadGateway, + fmt.Sprintf("cpex error on %s", hook), + "cpex.error", + err.Error(), + ) +} + +// matchesAnyHost reports whether host matches any glob in patterns +// using path.Match. The host is stripped of its port first so +// "keycloak.local:8081" matches "keycloak.*" naturally. Duplicated +// from ibac (small enough that the cross-plugin dependency isn't +// worth it; the bypass vocabulary may diverge over time). +func matchesAnyHost(patterns []string, host string) bool { + if host == "" { + return false + } + if i := strings.IndexByte(host, ':'); i >= 0 { + host = host[:i] + } + for _, p := range patterns { + if ok, _ := path.Match(p, host); ok { + return true + } + } + return false +} + +// sanitizeReason flattens an arbitrary string (a sub-plugin name, a +// reason code from CPEX, etc.) into the dotted form AuthBridge uses +// for violation codes — lowercase alphanumerics with underscores +// substituting for anything else. Keeps operator-visible codes stable +// across CPEX-side cosmetic changes. +func sanitizeReason(s string) string { + if s == "" { + return "unspecified" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + 32) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// applyDecision translates a Manager Result into a pipeline.Action + +// a `stop` flag the runHooks loop uses to decide whether to continue +// to the next hook. Deny stops the chain (returns Reject); other +// outcomes record the Invocation and let the chain proceed. +// +// DecisionModify records a modify Invocation; header/label and MCP body +// mutations are already applied by manager_cpex.go before we see the +// Result. +// +// DecisionError and any unrecognized decision fail CLOSED unless +// fail_open is set: a Result the plugin can't interpret must not +// silently allow traffic the policy may have meant to block. The +// fail_open routing matches handleInvokeError so an error surfaced as a +// Result behaves identically to one surfaced as a returned error. +func (p *CPEX) applyDecision(pctx *pipeline.Context, res Result) (action pipeline.Action, stop bool) { + switch res.Decision { + case DecisionAllow: + pctx.Allow(res.Reason) + return pipeline.Action{Type: pipeline.Continue}, false + case DecisionDeny: + // Namespace CPEX-emitted codes under cpex.* so operator + // dashboards group them distinctly from AuthBridge-native + // plugins. sanitizeReason normalizes whatever CPEX put in + // the violation Code to lower_underscore form. + code := "cpex.denied" + if res.Code != "" { + code = "cpex." + sanitizeReason(res.Code) + } + return denyWithStatus(pctx, http.StatusForbidden, res.Reason, code, res.Reason), true + case DecisionModify: + pctx.Modify(res.Reason) + return pipeline.Action{Type: pipeline.Continue}, false + case DecisionObserve: + pctx.Observe(res.Reason) + return pipeline.Action{Type: pipeline.Continue}, false + case DecisionError: + reason := res.Reason + if reason == "" { + reason = "cpex returned an error decision" + } + return p.handleDecisionError(pctx, errors.New(reason)), true + } + // Any unrecognized decision is a contract violation between the + // Manager and this chassis — fail closed (honoring fail_open) + // rather than allowing unconditionally. + return p.handleDecisionError(pctx, + fmt.Errorf("cpex: unknown decision %s", res.Decision)), true +} + +// handleDecisionError applies the fail_open policy to a DecisionError / +// unknown-decision Result. Mirrors handleInvokeError (which handles the +// returned-error path) so the two failure surfaces behave identically: +// fail_open=true → Observe + Continue; fail_open=false → Deny with code +// cpex.error. +func (p *CPEX) handleDecisionError(pctx *pipeline.Context, err error) pipeline.Action { + if p.cfg.FailOpen { + slog.Warn("cpex: error decision; allowing per fail_open=true", "error", err) + pctx.Observe(fmt.Sprintf("cpex error (fail_open): %v", err)) + return pipeline.Action{Type: pipeline.Continue} + } + return denyWithStatus(pctx, http.StatusBadGateway, + fmt.Sprintf("cpex error: %v", err), + "cpex.error", + err.Error(), + ) +} + +// denyWithStatus records a deny Invocation and returns a Reject carrying an +// explicit HTTP status. It exists because CPEX violation codes are +// namespaced and dynamic (cpex.denied, cpex.pii_detected, +// cpex.cedar_default_deny, …) and so never match the listener's static +// codeToStatus table; without an explicit status, Violation.Render would +// fall back to 500 for every CPEX outcome. Callers pass 403 for a policy +// deny (an authorization decision) and 502 for a CPEX-internal error (an +// upstream fault). recordReason is the machine-stable Invocation reason; +// message becomes the Violation's human-readable reason. +func denyWithStatus(pctx *pipeline.Context, status int, recordReason, code, message string) pipeline.Action { + pctx.Record(pipeline.Invocation{Action: pipeline.ActionDeny, Reason: recordReason}) + return pipeline.DenyStatus(status, code, message) +} diff --git a/authbridge/authlib/plugins/cpex/plugin_test.go b/authbridge/authlib/plugins/cpex/plugin_test.go new file mode 100644 index 000000000..0a69a2faf --- /dev/null +++ b/authbridge/authlib/plugins/cpex/plugin_test.go @@ -0,0 +1,669 @@ +package cpex + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// Canonical test-config strings. Most dispatch tests want a single +// request-phase hook wired so the chain has something to fire; a few +// configure tests use the empty config explicitly. +const ( + cfgOneRequestHook = `{"hooks":{"on_request":["cmf.tool_pre_invoke"]}}` + cfgEmpty = `{}` +) + +// setupFake returns a CPEX plugin pre-wired to construct the given +// FakeManager from Configure. Tests use this to bypass the +// production NewManager path (which under no-tag builds is the +// errNoCpexBuild stub). +func setupFake(fake *FakeManager) *CPEX { + p := NewCPEX() + p.newManager = func(_ ManagerOptions) (Manager, error) { return fake, nil } + return p +} + +// --- Configure --- + +func TestConfigure_EmptyConfigSucceeds(t *testing.T) { + // A cpex plugin with no hooks/apl/pipelines is a valid no-op + // install — operator can ship a config update later. + fake := &FakeManager{} + p := setupFake(fake) + if err := p.Configure([]byte(cfgEmpty)); err != nil { + t.Fatalf("Configure(empty): %v", err) + } + if fake.LoadedYAML != "" { + t.Fatalf("LoadedYAML = %q, want empty (no apl/pipelines blocks)", fake.LoadedYAML) + } +} + +func TestConfigure_RejectsUnknownField(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"not_a_field":1}`)) + if err == nil { + t.Fatal("Configure: expected error on unknown field") + } + if !strings.Contains(err.Error(), "not_a_field") { + t.Fatalf("error doesn't name the bad field: %v", err) + } +} + +func TestConfigure_RejectsNegativeWorkerThreads(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"worker_threads":-1}`)) + if err == nil { + t.Fatal("Configure: expected error on negative worker_threads") + } +} + +func TestConfigure_RejectsEmptyHookName(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"hooks":{"on_request":["cmf.tool_pre_invoke",""]}}`)) + if err == nil { + t.Fatal("Configure: expected error on empty hook name in chain") + } +} + +func TestConfigure_LoadConfigErrorPropagates(t *testing.T) { + fake := &FakeManager{LoadErr: errors.New("bad yaml")} + p := setupFake(fake) + err := p.Configure([]byte(`{"config":"plugins: garbage"}`)) + if err == nil || !strings.Contains(err.Error(), "bad yaml") { + t.Fatalf("Configure: want LoadConfig error to propagate, got %v", err) + } +} + +func TestConfigure_InlineConfigLoadedIntoCPEX(t *testing.T) { + // Operator supplies the full CPEX YAML inline via `config:`. We + // hand it to Manager.LoadConfig verbatim — no re-shaping. This + // matches what operators read in CPEX's own docs (`plugins:`, + // `global:`, `plugin_settings:` at the top level). + fake := &FakeManager{} + p := setupFake(fake) + yaml := "plugins:\n - name: identity-jwt\n kind: identity/jwt\n" + raw := `{"config":"` + strings.ReplaceAll(yaml, "\n", `\n`) + `"}` + if err := p.Configure([]byte(raw)); err != nil { + t.Fatalf("Configure: %v", err) + } + if fake.LoadedYAML != yaml { + t.Fatalf("LoadedYAML = %q, want %q (verbatim passthrough)", fake.LoadedYAML, yaml) + } +} + +func TestConfigure_ConfigFileReadsFromDisk(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cpex.yaml") + want := "plugins:\n - name: noop\n kind: identity/none\n" + if err := os.WriteFile(path, []byte(want), 0o600); err != nil { + t.Fatalf("write tempfile: %v", err) + } + fake := &FakeManager{} + p := setupFake(fake) + raw := []byte(`{"config_file":"` + path + `"}`) + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure config_file: %v", err) + } + if fake.LoadedYAML != want { + t.Fatalf("LoadedYAML = %q, want %q (read from %s)", fake.LoadedYAML, want, path) + } +} + +func TestConfigure_RejectsBothConfigAndFile(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"config":"plugins: []","config_file":"/dev/null"}`)) + if err == nil { + t.Fatal("Configure: expected error when both config and config_file set") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("error message lost 'mutually exclusive' hint: %v", err) + } +} + +func TestConfigure_ConfigFileMissingPropagatesError(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"config_file":"/definitely/not/a/path"}`)) + if err == nil { + t.Fatal("Configure: expected error when config_file missing") + } +} + +func TestConfigure_NoConfigSkipsLoadConfig(t *testing.T) { + // When the operator hasn't supplied config / config_file, we + // don't call LoadConfig at all — saves a YAML parse cycle on the + // CPEX side for the "install with bypass only" case. + fake := &FakeManager{} + p := setupFake(fake) + if err := p.Configure([]byte(cfgOneRequestHook)); err != nil { + t.Fatalf("Configure: %v", err) + } + if fake.LoadedYAML != "" { + t.Fatalf("LoadedYAML = %q, want empty (no config supplied)", fake.LoadedYAML) + } +} + +func TestConfigure_PassesWorkerThreadsToFactory(t *testing.T) { + var seenOpts ManagerOptions + fake := &FakeManager{} + p := NewCPEX() + p.newManager = func(opts ManagerOptions) (Manager, error) { + seenOpts = opts + return fake, nil + } + if err := p.Configure([]byte(`{"worker_threads":4}`)); err != nil { + t.Fatalf("Configure: %v", err) + } + if seenOpts.WorkerThreads != 4 { + t.Fatalf("WorkerThreads = %d, want 4", seenOpts.WorkerThreads) + } +} + +func TestConfigure_RejectsWildcardBypassHost(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"bypass_hosts":["*"]}`)) + if err == nil { + t.Fatal("Configure: expected error for wildcard bypass_hosts pattern") + } +} + +func TestConfigure_RejectsWildcardBypassPath(t *testing.T) { + p := setupFake(&FakeManager{}) + err := p.Configure([]byte(`{"bypass_paths":["/*"]}`)) + if err == nil { + t.Fatal("Configure: expected error for wildcard bypass_paths pattern") + } +} + +// --- Init / Shutdown / Ready --- + +func TestInit_FlipsReadyTrue(t *testing.T) { + fake := &FakeManager{} + p := setupFake(fake) + mustConfigure(t, p, cfgOneRequestHook) + if p.Ready() { + t.Fatal("Ready true before Init") + } + if err := p.Init(context.Background()); err != nil { + t.Fatalf("Init: %v", err) + } + if !fake.Initialized { + t.Fatal("manager.Initialize not called") + } + if !p.Ready() { + t.Fatal("Ready false after Init") + } +} + +func TestInit_PropagatesManagerError(t *testing.T) { + fake := &FakeManager{InitErr: errors.New("upstream down")} + p := setupFake(fake) + mustConfigure(t, p, cfgOneRequestHook) + err := p.Init(context.Background()) + if err == nil || !strings.Contains(err.Error(), "upstream down") { + t.Fatalf("Init: want upstream error, got %v", err) + } + if p.Ready() { + t.Fatal("Ready true after failed Init") + } +} + +func TestInit_WithoutConfigureErrors(t *testing.T) { + p := NewCPEX() + if err := p.Init(context.Background()); err == nil { + t.Fatal("Init: expected error when Configure not called") + } +} + +func TestShutdown_FlipsReadyFalse(t *testing.T) { + fake := &FakeManager{} + p := setupFake(fake) + mustConfigure(t, p, cfgOneRequestHook) + if err := p.Init(context.Background()); err != nil { + t.Fatalf("Init: %v", err) + } + if err := p.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown: %v", err) + } + if !fake.ShutdownCalled { + t.Fatal("manager.Shutdown not called") + } + if p.Ready() { + t.Fatal("Ready still true after Shutdown") + } +} + +func TestShutdown_WithoutConfigureIsNoOp(t *testing.T) { + p := NewCPEX() + if err := p.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown without Configure should be no-op, got %v", err) + } +} + +// --- Capabilities / Name / ConfigSchema --- + +func TestName(t *testing.T) { + if name := NewCPEX().Name(); name != "cpex" { + t.Fatalf("Name = %q, want %q", name, "cpex") + } +} + +func TestCapabilities_RequiresAnyParser(t *testing.T) { + caps := NewCPEX().Capabilities() + if !caps.ReadsBody || !caps.WritesBody { + t.Fatal("ReadsBody/WritesBody must be true: CPEX policies routinely mutate payloads") + } + want := []string{"mcp-parser", "inference-parser", "a2a-parser"} + if len(caps.RequiresAny) != len(want) { + t.Fatalf("RequiresAny = %v, want %v", caps.RequiresAny, want) + } + for i, p := range want { + if caps.RequiresAny[i] != p { + t.Fatalf("RequiresAny[%d] = %q, want %q", i, caps.RequiresAny[i], p) + } + } + if caps.Description == "" { + t.Fatal("Description empty — abctl catalog renders blank") + } +} + +func TestConfigSchema_IncludesExpectedFields(t *testing.T) { + schema := NewCPEX().ConfigSchema() + if len(schema) == 0 { + t.Fatal("ConfigSchema empty") + } + have := map[string]bool{} + for _, f := range schema { + have[f.Name] = true + } + for _, name := range []string{ + "hooks", "config", "config_file", + "fail_open", "worker_threads", + "bypass_hosts", "bypass_paths", + } { + if !have[name] { + t.Errorf("ConfigSchema missing field %q", name) + } + } +} + +// --- OnRequest / OnResponse dispatch --- + +func TestDispatch_EmptyHooksContinues(t *testing.T) { + // No hooks configured → phase is a no-op. Manager is never called. + fake := &FakeManager{} + p := setupAndInit(t, fake, cfgEmpty) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue", a.Type) + } + if len(fake.Invokes) != 0 { + t.Fatalf("Invoke called %d times, want 0 (no hooks)", len(fake.Invokes)) + } +} + +func TestDispatch_UnknownHookSkipsAndContinuesChain(t *testing.T) { + // Operator listed hook in config but no sub-plugin is wired for it + // on CPEX. We skip that hook (recording Invocation.Skip) and proceed + // to the next hook in the chain. The second hook here is wired and + // returns Allow. + fake := &FakeManager{ + KnownHooks: []string{HookToolPostInvoke}, + Hooks: map[string]Result{ + HookToolPostInvoke: {Decision: DecisionAllow, Reason: "ok"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke","cmf.tool_post_invoke"]}}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue", a.Type) + } + if len(fake.Invokes) != 1 || fake.Invokes[0].Hook != HookToolPostInvoke { + t.Fatalf("Invokes = %#v, want one call to %s", fake.Invokes, HookToolPostInvoke) + } +} + +func TestDispatch_AllowDecisionContinues(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionAllow, Reason: "ok"}, + }, + } + p := setupAndInit(t, fake, cfgOneRequestHook) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue", a.Type) + } + if len(fake.Invokes) != 1 || fake.Invokes[0].Hook != HookToolPreInvoke { + t.Fatalf("Invoke called with %#v, want one call to %s", fake.Invokes, HookToolPreInvoke) + } +} + +func TestDispatch_DenyDecisionRejectsAndShortCircuits(t *testing.T) { + // CPEX-emitted codes get namespaced under cpex.* and sanitized + // to lower_underscore form. Input "cedar.denied" → "cpex.cedar_denied". + // The chain stops after deny — the second hook never fires. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke, HookToolPostInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionDeny, Code: "cedar.denied", Reason: "policy says no"}, + HookToolPostInvoke: {Decision: DecisionAllow, Reason: "should never run"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke","cmf.tool_post_invoke"]}}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Reject { + t.Fatalf("Type = %d, want Reject", a.Type) + } + if a.Violation == nil || a.Violation.Code != "cpex.cedar_denied" || a.Violation.Reason != "policy says no" { + t.Fatalf("Violation = %#v, want code=cpex.cedar_denied reason=\"policy says no\"", a.Violation) + } + // A policy deny is an authorization decision → 403, not the 500 a + // namespaced cpex.* code would otherwise default to via StatusFromCode. + if a.Violation.Status != http.StatusForbidden { + t.Fatalf("Violation.Status = %d, want 403 (policy deny)", a.Violation.Status) + } + if len(fake.Invokes) != 1 { + t.Fatalf("Invokes = %d, want 1 (chain should short-circuit on first deny)", len(fake.Invokes)) + } +} + +func TestDispatch_DenyWithoutCodeDefaultsToCpexDenied(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionDeny, Reason: "no code"}, + }, + } + p := setupAndInit(t, fake, cfgOneRequestHook) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Reject || a.Violation == nil || a.Violation.Code != "cpex.denied" { + t.Fatalf("want Reject with code=cpex.denied, got %#v", a) + } + if a.Violation.Status != http.StatusForbidden { + t.Fatalf("Violation.Status = %d, want 403 (policy deny)", a.Violation.Status) + } +} + +func TestDispatch_ChainAllowedHooksAllFire(t *testing.T) { + // Allow on every hook → all hooks in the chain fire in order. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke, HookToolPostInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionAllow, Reason: "ok"}, + HookToolPostInvoke: {Decision: DecisionAllow, Reason: "ok"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke","cmf.tool_post_invoke"]}}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue", a.Type) + } + if len(fake.Invokes) != 2 { + t.Fatalf("Invokes = %d, want 2", len(fake.Invokes)) + } + if fake.Invokes[0].Hook != HookToolPreInvoke || fake.Invokes[1].Hook != HookToolPostInvoke { + t.Fatalf("Hook order = %s,%s, want %s,%s", + fake.Invokes[0].Hook, fake.Invokes[1].Hook, + HookToolPreInvoke, HookToolPostInvoke) + } +} + +func TestDispatch_ModifyDecisionContinuesChain(t *testing.T) { + // Modify is a non-terminal outcome — the next hook in the chain + // still fires. Headers/labels modifications are applied by + // manager_cpex.go inside Invoke before we see the Result. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke, HookToolPostInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionModify, Reason: "redacted email"}, + HookToolPostInvoke: {Decision: DecisionAllow, Reason: "ok"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke","cmf.tool_post_invoke"]}}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue", a.Type) + } + if len(fake.Invokes) != 2 { + t.Fatalf("Invokes = %d, want 2 (Modify shouldn't stop the chain)", len(fake.Invokes)) + } +} + +func TestDispatch_ObserveContinues(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionObserve, Reason: "logged"}, + }, + } + p := setupAndInit(t, fake, cfgOneRequestHook) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue", a.Type) + } +} + +func TestDispatch_InvokeErrorFailClosed(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + InvokeErr: errors.New("ffi exploded"), + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"]},"fail_open":false}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Reject { + t.Fatalf("Type = %d, want Reject (fail-closed default)", a.Type) + } + if a.Violation == nil || a.Violation.Code != "cpex.error" { + t.Fatalf("want Violation code=cpex.error, got %#v", a.Violation) + } + // A CPEX-internal failure is an upstream fault → 502. + if a.Violation.Status != http.StatusBadGateway { + t.Fatalf("Violation.Status = %d, want 502 (cpex error)", a.Violation.Status) + } +} + +func TestDispatch_InvokeErrorFailOpen(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + InvokeErr: errors.New("ffi exploded"), + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"]},"fail_open":true}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue (fail_open=true), got %#v", a.Type, a) + } +} + +func TestDispatch_ErrorDecisionFailClosed(t *testing.T) { + // A Manager that returns DecisionError WITHOUT a Go error (e.g. an + // unappliable body modification mapped to DecisionError, or a future + // code path) must fail closed when fail_open=false — never silently + // allow. Mirrors the returned-error fail-closed path. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionError, Reason: "redaction unappliable"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"]},"fail_open":false}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Reject { + t.Fatalf("Type = %d, want Reject (DecisionError fail-closed)", a.Type) + } + if a.Violation == nil || a.Violation.Code != "cpex.error" { + t.Fatalf("want Violation code=cpex.error, got %#v", a.Violation) + } + if a.Violation.Status != http.StatusBadGateway { + t.Fatalf("Violation.Status = %d, want 502 (cpex error)", a.Violation.Status) + } +} + +func TestDispatch_ErrorDecisionFailOpen(t *testing.T) { + // Same DecisionError, but fail_open=true → Observe + Continue. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionError, Reason: "redaction unappliable"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"]},"fail_open":true}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue (DecisionError fail_open=true), got %#v", a.Type, a) + } +} + +func TestSanitizeReason(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "unspecified"}, + {"CEDAR.DENIED", "cedar_denied"}, + {"pii.redacted-field", "pii_redacted_field"}, + {"café", "caf_"}, // non-ASCII rune → single underscore + } + for _, tc := range cases { + if got := sanitizeReason(tc.in); got != tc.want { + t.Errorf("sanitizeReason(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestDispatch_PathBypassSkipsInvoke(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionDeny, Reason: "would deny"}, + }, + } + // /healthz must skip CPEX entirely (default bypass pattern), even + // with a deny configured for the hook. + p := setupAndInit(t, fake, cfgOneRequestHook) + pctx := &pipeline.Context{Path: "/healthz"} + a := p.OnRequest(context.Background(), pctx) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue (path bypass)", a.Type) + } + if len(fake.Invokes) != 0 { + t.Fatalf("Invoke called %d times, want 0 (path bypass should skip FFI)", len(fake.Invokes)) + } +} + +func TestDispatch_HostBypassSkipsInvoke(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionDeny, Reason: "would deny"}, + }, + } + // keycloak.* is in defaultBypassHosts; port stripping handles + // "keycloak.local:8081" cleanly. + p := setupAndInit(t, fake, cfgOneRequestHook) + pctx := &pipeline.Context{Host: "keycloak.local:8081"} + a := p.OnRequest(context.Background(), pctx) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue (host bypass)", a.Type) + } + if len(fake.Invokes) != 0 { + t.Fatalf("Invoke called %d times, want 0 (host bypass should skip FFI)", len(fake.Invokes)) + } +} + +func TestConfigure_CustomBypassListsHonored(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionAllow, Reason: "ok"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"]},"bypass_hosts":["internal.svc"],"bypass_paths":["/api/internal/*"]}` + p := setupAndInit(t, fake, cfg) + // /healthz NO LONGER bypasses (operator narrowed the set), so the + // hook fires for it. + p.OnRequest(context.Background(), &pipeline.Context{Path: "/healthz"}) + if len(fake.Invokes) != 1 { + t.Fatalf("Invoke called %d times, want 1 (custom bypass list shouldn't include /healthz)", len(fake.Invokes)) + } +} + +func TestDispatch_OnResponseUsesResponseHooks(t *testing.T) { + // OnRequest and OnResponse use distinct hook lists. Verify they're + // dispatched independently. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke, HookToolPostInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionAllow, Reason: "req"}, + HookToolPostInvoke: {Decision: DecisionAllow, Reason: "resp"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"],"on_response":["cmf.tool_post_invoke"]}}` + p := setupAndInit(t, fake, cfg) + p.OnRequest(context.Background(), &pipeline.Context{}) + p.OnResponse(context.Background(), &pipeline.Context{}) + if len(fake.Invokes) != 2 { + t.Fatalf("Invokes = %d, want 2", len(fake.Invokes)) + } + if fake.Invokes[0].Hook != HookToolPreInvoke || fake.Invokes[1].Hook != HookToolPostInvoke { + t.Fatalf("Invokes = %v, want [%s, %s]", + fake.Invokes, HookToolPreInvoke, HookToolPostInvoke) + } +} + +func TestDispatch_NotReadyIsNoOp(t *testing.T) { + // p.Init never called → p.ready == false → dispatch returns Continue + // without touching the manager. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionDeny, Reason: "would deny"}, + }, + } + p := setupFake(fake) + mustConfigure(t, p, cfgOneRequestHook) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Continue { + t.Fatalf("Type = %d, want Continue when not ready", a.Type) + } + if len(fake.Invokes) != 0 { + t.Fatalf("Invoke called %d times, want 0 when not ready", len(fake.Invokes)) + } +} + +// --- helpers --- + +func mustConfigure(t *testing.T, p *CPEX, raw string) { + t.Helper() + if err := p.Configure([]byte(raw)); err != nil { + t.Fatalf("Configure: %v", err) + } +} + +// setupAndInit returns a plugin that's Configure'd against the given +// fake and Init'd (so p.ready is true). Most dispatch tests need this +// — Configure alone leaves the plugin no-op until Init flips ready. +func setupAndInit(t *testing.T, fake *FakeManager, raw string) *CPEX { + t.Helper() + p := setupFake(fake) + mustConfigure(t, p, raw) + if err := p.Init(context.Background()); err != nil { + t.Fatalf("Init: %v", err) + } + return p +} diff --git a/authbridge/authlib/plugins/jwtvalidation/identity.go b/authbridge/authlib/plugins/jwtvalidation/identity.go index da46ef326..7ecac1f9f 100644 --- a/authbridge/authlib/plugins/jwtvalidation/identity.go +++ b/authbridge/authlib/plugins/jwtvalidation/identity.go @@ -1,6 +1,12 @@ package jwtvalidation -import "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation" +import ( + "strconv" + "strings" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation" +) // claimsIdentity adapts a *validation.Claims to the pipeline.Identity // interface that Context exposes. Kept in the plugin so the pipeline @@ -8,10 +14,20 @@ import "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvali // // A nil *validation.Claims would cause NPEs on the accessor methods, // so jwt-validation only wraps non-nil Claims. +// +// Beyond the minimal pipeline.Identity surface it also implements +// contracts.ClaimsCarrier so richer consumers (the cpex plugin) can read +// issuer / audience / auth-method / curated claims without +// pipeline.Identity growing. type claimsIdentity struct { c *validation.Claims } +// Compile-time assertion: claimsIdentity exposes the richer claim +// surface. If contracts.ClaimsCarrier gains a method, this line forces +// the adapter to keep up at `go build`. +var _ contracts.ClaimsCarrier = claimsIdentity{} + func (i claimsIdentity) Subject() string { if i.c == nil { return "" @@ -32,3 +48,51 @@ func (i claimsIdentity) Scopes() []string { } return i.c.Scopes } + +// Issuer implements contracts.ClaimsCarrier. +func (i claimsIdentity) Issuer() string { + if i.c == nil { + return "" + } + return i.c.Issuer +} + +// Audience implements contracts.ClaimsCarrier. +func (i claimsIdentity) Audience() []string { + if i.c == nil { + return nil + } + return i.c.Audience +} + +// AuthMethod implements contracts.ClaimsCarrier. A claimsIdentity is only +// ever constructed from a verified JWT, so the method is always "jwt". +func (i claimsIdentity) AuthMethod() string { + return "jwt" +} + +// Claims implements contracts.ClaimsCarrier. It returns a deliberately +// SMALL, string-valued subset — issuer, audience (comma-joined), and +// expiry (Unix seconds) — never the full raw `Extra` claim map. Those +// three are the keys policy commonly branches on; dumping the entire +// claim set would risk forwarding secrets/PII across the FFI and into +// CPEX traces / the session API. +func (i claimsIdentity) Claims() map[string]string { + if i.c == nil { + return nil + } + out := make(map[string]string, 3) + if i.c.Issuer != "" { + out["issuer"] = i.c.Issuer + } + if len(i.c.Audience) > 0 { + out["audience"] = strings.Join(i.c.Audience, ",") + } + if !i.c.ExpiresAt.IsZero() { + out["exp"] = strconv.FormatInt(i.c.ExpiresAt.Unix(), 10) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/authbridge/authlib/plugins/jwtvalidation/identity_test.go b/authbridge/authlib/plugins/jwtvalidation/identity_test.go new file mode 100644 index 000000000..a5acc7aab --- /dev/null +++ b/authbridge/authlib/plugins/jwtvalidation/identity_test.go @@ -0,0 +1,69 @@ +package jwtvalidation + +import ( + "reflect" + "testing" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation/validation" +) + +func TestClaimsIdentity_BasicAccessors(t *testing.T) { + id := claimsIdentity{c: &validation.Claims{ + Subject: "alice", + ClientID: "agent-x", + Scopes: []string{"hr.read", "hr.write"}, + }} + if id.Subject() != "alice" { + t.Errorf("Subject = %q", id.Subject()) + } + if id.ClientID() != "agent-x" { + t.Errorf("ClientID = %q", id.ClientID()) + } + if !reflect.DeepEqual(id.Scopes(), []string{"hr.read", "hr.write"}) { + t.Errorf("Scopes = %v", id.Scopes()) + } +} + +func TestClaimsIdentity_ClaimsCarrier(t *testing.T) { + // Compile-time + runtime: the adapter is a ClaimsCarrier. + var cc contracts.ClaimsCarrier = claimsIdentity{c: &validation.Claims{ + Subject: "alice", + Issuer: "https://kc/realms/kagenti", + Audience: []string{"hr-agent", "github-tool"}, + ExpiresAt: time.Unix(1893456000, 0), + }} + + if cc.AuthMethod() != "jwt" { + t.Errorf("AuthMethod = %q, want jwt", cc.AuthMethod()) + } + if cc.Issuer() != "https://kc/realms/kagenti" { + t.Errorf("Issuer = %q", cc.Issuer()) + } + if !reflect.DeepEqual(cc.Audience(), []string{"hr-agent", "github-tool"}) { + t.Errorf("Audience = %v", cc.Audience()) + } + + claims := cc.Claims() + want := map[string]string{ + "issuer": "https://kc/realms/kagenti", + "audience": "hr-agent,github-tool", + "exp": "1893456000", + } + if !reflect.DeepEqual(claims, want) { + t.Errorf("Claims = %v, want %v", claims, want) + } +} + +func TestClaimsIdentity_CuratedClaimsOmitsEmpty(t *testing.T) { + // Only subject present — Claims() returns nil rather than a map of + // empty strings, and never leaks a raw Extra map. + id := claimsIdentity{c: &validation.Claims{ + Subject: "bob", + Extra: map[string]any{"secret": "do-not-forward"}, + }} + if got := id.Claims(); got != nil { + t.Fatalf("Claims with no issuer/aud/exp should be nil, got %v", got) + } +} diff --git a/authbridge/authlib/plugins/tokenexchange/delegation_test.go b/authbridge/authlib/plugins/tokenexchange/delegation_test.go new file mode 100644 index 000000000..066059cf6 --- /dev/null +++ b/authbridge/authlib/plugins/tokenexchange/delegation_test.go @@ -0,0 +1,77 @@ +package tokenexchange + +import ( + "reflect" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +func TestSplitScopes(t *testing.T) { + if got := splitScopes("openid github-tool-aud github-full-access"); !reflect.DeepEqual( + got, []string{"openid", "github-tool-aud", "github-full-access"}) { + t.Fatalf("got %v", got) + } + if got := splitScopes(""); got != nil { + t.Fatalf("empty scopes should be nil, got %v", got) + } + if got := splitScopes(" spaced out "); !reflect.DeepEqual(got, []string{"spaced", "out"}) { + t.Fatalf("extra whitespace not collapsed: %v", got) + } +} + +func TestRecordDelegationHop_AppendsExchangeHop(t *testing.T) { + pctx := &pipeline.Context{} + result := &auth.OutboundResult{ + Action: auth.ActionReplaceToken, + CacheHit: true, + TargetAudience: "github-tool", + RequestedScopes: "openid github-tool-aud", + } + recordDelegationHop(pctx, result) + + d := pctx.Extensions.Delegation + if d == nil { + t.Fatal("delegation extension not created") + } + if d.Depth() != 1 { + t.Fatalf("depth = %d, want 1", d.Depth()) + } + hop := d.Chain()[0] + if hop.Audience != "github-tool" { + t.Errorf("audience = %q", hop.Audience) + } + if hop.Strategy != "token-exchange" { + t.Errorf("strategy = %q", hop.Strategy) + } + if !hop.FromCache { + t.Errorf("from-cache not propagated") + } + if !reflect.DeepEqual(hop.Scopes, []string{"openid", "github-tool-aud"}) { + t.Errorf("scopes = %v", hop.Scopes) + } + if hop.Timestamp.IsZero() { + t.Errorf("timestamp should be stamped") + } +} + +func TestRecordDelegationHop_SubjectFromIdentityWhenPresent(t *testing.T) { + pctx := &pipeline.Context{Identity: stubIdentity{subject: "alice"}} + recordDelegationHop(pctx, &auth.OutboundResult{TargetAudience: "tool", RequestedScopes: ""}) + if got := pctx.Extensions.Delegation.Chain()[0].SubjectID; got != "alice" { + t.Fatalf("subject = %q, want alice", got) + } + // Origin/Actor derive from the hop subject. + if pctx.Extensions.Delegation.Origin != "alice" || pctx.Extensions.Delegation.Actor != "alice" { + t.Fatalf("origin/actor not derived: origin=%q actor=%q", + pctx.Extensions.Delegation.Origin, pctx.Extensions.Delegation.Actor) + } +} + +// stubIdentity is a minimal pipeline.Identity for the subject-present path. +type stubIdentity struct{ subject string } + +func (s stubIdentity) Subject() string { return s.subject } +func (s stubIdentity) ClientID() string { return "" } +func (s stubIdentity) Scopes() []string { return nil } diff --git a/authbridge/authlib/plugins/tokenexchange/plugin.go b/authbridge/authlib/plugins/tokenexchange/plugin.go index 2fa315cb1..b73c682d4 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -10,6 +10,7 @@ import ( "net/http" "strings" "sync/atomic" + "time" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" @@ -608,6 +609,7 @@ func (p *TokenExchange) OnRequest(ctx context.Context, pctx *pipeline.Context) p return pipeline.DenyStatus(result.DenyStatus, code, result.DenyReason) case auth.ActionReplaceToken: pctx.Headers.Set("Authorization", "Bearer "+result.Token) + recordDelegationHop(pctx, result) reason := "token_replaced" if result.CacheHit { reason = "cache_hit" @@ -669,6 +671,49 @@ func boolStr(b bool) string { return "false" } +// recordDelegationHop appends a delegation hop to pctx.Extensions.Delegation +// whenever token-exchange mints (or cache-serves) a downstream token. This +// is AuthBridge's own auth plugin establishing delegation provenance — the +// chain is then forwarded into policy context (CPEX's DelegationExtension) +// and surfaced on the session API, giving downstream policy a record of +// what audience the agent's token was exchanged for, with which scopes, and +// whether it came from cache. +// +// SubjectID is best-effort: the forward-proxy outbound pctx generally has +// no validated Identity (JWT validation runs on the separate inbound pass), +// so it's populated only when an Identity happens to be present. The +// audience / scopes / strategy / from-cache fields are always known from +// the exchange result and carry the bulk of the signal regardless. +func recordDelegationHop(pctx *pipeline.Context, result *auth.OutboundResult) { + if pctx.Extensions.Delegation == nil { + pctx.Extensions.Delegation = &pipeline.DelegationExtension{} + } + subject := "" + if pctx.Identity != nil { + subject = pctx.Identity.Subject() + } + pctx.Extensions.Delegation.AppendHop(pipeline.DelegationHop{ + SubjectID: subject, + Scopes: splitScopes(result.RequestedScopes), + Timestamp: time.Now(), + Audience: result.TargetAudience, + Strategy: "token-exchange", + FromCache: result.CacheHit, + }) +} + +// splitScopes splits a raw space-separated OAuth scope string into a +// slice, dropping empty fields. Returns nil (not an empty slice) for an +// unscoped exchange so the hop's ScopesGranted stays absent rather than +// an empty list. +func splitScopes(raw string) []string { + fields := strings.Fields(raw) + if len(fields) == 0 { + return nil + } + return fields +} + func (p *TokenExchange) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION new file mode 100644 index 000000000..f945facd4 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION @@ -0,0 +1 @@ +v0.2.0-ffi.test.7 diff --git a/authbridge/cmd/authbridge-cpex/Dockerfile b/authbridge/cmd/authbridge-cpex/Dockerfile new file mode 100644 index 000000000..d010f6bfb --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/Dockerfile @@ -0,0 +1,111 @@ +# AuthBridge CPEX-enabled sidecar image — authbridge-proxy (HTTP +# forward + reverse proxies) plus the `cpex` plugin which routes +# hooks through the CPEX framework (APL DSL + named CPEX policy +# plugins). Built with -tags cpex; links libcpex_ffi.a from a +# pinned CPEX release. +# +# Build context: ./authbridge (needs authlib/ and cmd/authbridge-cpex/) +# +# Why not just authbridge-proxy: that image is pure-Go, CGO_ENABLED=0. +# CPEX requires cgo. Operators switch images (not build flags) to +# enable CPEX. + +# ---------- Stage 1: fetch libcpex_ffi.a from the pinned release ---- +# The CPEX_FFI_VERSION file at the root of this directory is the +# single source of truth for which CPEX FFI ABI this binary expects. +# A pre-commit hook (TODO follow-up) enforces it matches the go.mod +# pin for the cpex Go bindings. +# +# Asset naming on the cpex release page: +# cpex-ffi-${VERSION}-${OS}-${ARCH}-${LIBC}.tar.gz (tarball) +# cpex-ffi-${VERSION}-${OS}-${ARCH}-${LIBC}.tar.gz.sha256 +# cpex-ffi-${VERSION}-${OS}-${ARCH}-${LIBC}.tar.gz.sig (cosign) +# cpex-ffi-${VERSION}-${OS}-${ARCH}-${LIBC}.tar.gz.crt (cosign) +# +# Tarball contents: +# ./libcpex_ffi.a +# ./VERSION +# ./FFI_ABI +# ./LICENSE +# +# This stage fetches the tarball + its .sha256 sidecar, verifies the +# checksum, extracts the .a. Cosign verification is a follow-up (we +# have the .sig/.crt to wire it up); for now the sha256 is the +# integrity check. +FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d AS ffi + +RUN apk add --no-cache curl ca-certificates tar + +ARG CPEX_FFI_VERSION +ARG TARGETARCH +ARG TARGETOS=linux +# LIBC defaults to gnu (Debian/Ubuntu container hosts). Override to +# `musl` for Alpine-based deployments. +ARG CPEX_LIBC=gnu +ARG FFI_ASSET=cpex-ffi-${CPEX_FFI_VERSION}-${TARGETOS}-${TARGETARCH}-${CPEX_LIBC}.tar.gz + +WORKDIR /ffi +RUN set -eu; \ + BASE="https://github.com/contextforge-org/cpex/releases/download/${CPEX_FFI_VERSION}"; \ + echo "[ffi] downloading ${BASE}/${FFI_ASSET}"; \ + curl -fsSLo asset.tar.gz "${BASE}/${FFI_ASSET}"; \ + curl -fsSLo asset.tar.gz.sha256 "${BASE}/${FFI_ASSET}.sha256"; \ + # sha256 file format: " " — rename our local copy \ + # so the line matches, then verify. \ + expected_hash=$(awk '{print $1}' asset.tar.gz.sha256); \ + actual_hash=$(sha256sum asset.tar.gz | awk '{print $1}'); \ + if [ "$expected_hash" != "$actual_hash" ]; then \ + echo "[ffi] sha256 mismatch: expected $expected_hash, got $actual_hash" >&2; \ + exit 1; \ + fi; \ + echo "[ffi] sha256 verified"; \ + tar -xzf asset.tar.gz; \ + test -f libcpex_ffi.a || { echo "[ffi] libcpex_ffi.a missing from tarball" >&2; exit 1; }; \ + echo "[ffi] extracted libcpex_ffi.a + VERSION=$(cat VERSION) + FFI_ABI=$(cat FFI_ABI)" + +# ---------- Stage 2: build authbridge-cpex with cgo + -tags cpex ---- +# golang:bookworm (glibc) — alpine + musl would need extra ring/ +# tokio cross-compile setup that the design doc defers to PR 2. +FROM golang:1.25-bookworm@sha256:bb979b278ffb8d31c8b07336fd187ef8fafc8766ebeaece524304483ea137e96 AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY authlib/ authlib/ +COPY cmd/authbridge-cpex/ cmd/authbridge-cpex/ + +COPY --from=ffi /ffi/libcpex_ffi.a /libs/libcpex_ffi.a + +ENV GOWORK=off +ENV CGO_ENABLED=1 +ENV CGO_LDFLAGS="-L/libs -lcpex_ffi -lm -ldl" +# Let Go auto-download the toolchain version go.mod requires. The +# pinned base image carries 1.25.1; cpex bindings need 1.25.4 (and +# transitively bumps our cmd/authbridge-cpex/go.mod). GOTOOLCHAIN=auto +# means "download the version go.mod asks for" — keeps the base image +# pin stable across toolchain bumps. +ENV GOTOOLCHAIN=auto +RUN cd cmd/authbridge-cpex && \ + go build -tags cpex -ldflags="-s -w" -o /authbridge-cpex . + +# ---------- Stage 3: runtime ---------------------------------------- +# debian:trixie (Debian 13) for GLIBC 2.39+ — the cpex release .a is +# built against that floor. bookworm (Debian 12, GLIBC 2.36) link-loads +# but fails at runtime with "GLIBC_2.39 not found". When we eventually +# move to a statically-linked CGO build (or the musl asset variant), +# we can downgrade this back to the smaller base. +FROM debian:trixie-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder --chmod=755 /authbridge-cpex /usr/local/bin/authbridge-cpex +COPY --chmod=755 cmd/authbridge-cpex/entrypoint.sh /usr/local/bin/entrypoint.sh + +USER 1001 + +EXPOSE 8080 8081 9091 9093 9094 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/authbridge/cmd/authbridge-cpex/entrypoint.sh b/authbridge/cmd/authbridge-cpex/entrypoint.sh new file mode 100644 index 000000000..6ec8ded06 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -eu + +# AuthBridge CPEX-enabled sidecar entrypoint with process supervision. +# Manages: authbridge-cpex (authbridge-proxy + CPEX plugin). +# +# Identical to cmd/authbridge-proxy/entrypoint.sh except for the +# binary name; promote to a shared script when one is extracted. + +CRITICAL_PIDS="" + +cleanup() { + echo "[entrypoint] Received signal, shutting down..." + # shellcheck disable=SC2086 + kill $CRITICAL_PIDS 2>/dev/null || true + wait + exit 0 +} +trap cleanup TERM INT + +echo "[entrypoint] Starting authbridge-cpex..." +/usr/local/bin/authbridge-cpex "$@" & +CRITICAL_PIDS="$CRITICAL_PIDS $!" + +# shellcheck disable=SC2086 +wait -n $CRITICAL_PIDS +EXIT_CODE=$? +echo "[entrypoint] A critical process exited unexpectedly (exit code $EXIT_CODE), terminating container" +# shellcheck disable=SC2086 +kill $CRITICAL_PIDS 2>/dev/null || true +wait +exit 1 diff --git a/authbridge/cmd/authbridge-cpex/go.mod b/authbridge/cmd/authbridge-cpex/go.mod new file mode 100644 index 000000000..45c9ead55 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/go.mod @@ -0,0 +1,35 @@ +module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-cpex + +go 1.25.4 + +require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101000000-000000000000 + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../../authlib diff --git a/authbridge/cmd/authbridge-cpex/go.sum b/authbridge/cmd/authbridge-cpex/go.sum new file mode 100644 index 000000000..08d1043fb --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -0,0 +1,94 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 h1:oKlTk1+q00z0/YdnZUepelzqKa3Xc1wB+o6//L4DsHM= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/cmd/authbridge-cpex/main.go b/authbridge/cmd/authbridge-cpex/main.go new file mode 100644 index 000000000..fc209a1e8 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/main.go @@ -0,0 +1,360 @@ +//go:build cpex + +// Package main is the CPEX-enabled authbridge binary: identical to +// authbridge-proxy (HTTP forward + reverse proxies, full kagenti +// plugin set) plus the `cpex` plugin which routes hooks through the +// CPEX (Context Plugin Execution) framework — including the APL DSL +// and any pre-built CPEX policy plugins (Cedar, PII scanner, audit +// logger, etc.). +// +// This binary requires `-tags cpex` and links libcpex_ffi via cgo. +// The build constraint at the top of this file ensures a no-tag +// build fails fast rather than silently producing an authbridge-proxy +// duplicate. +// +// For envoy-sidecar mode use authbridge-envoy; for a no-cgo, pure-Go +// build use authbridge-proxy. The body of main() below is duplicated +// from authbridge-proxy/main.go pending an authlib-side `Run()` +// extraction — see this binary's README for the extraction proposal. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/observe" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/shared" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe" + authtls "github.com/kagenti/kagenti-extensions/authbridge/authlib/tls" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/forwardproxy" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/reverseproxy" + + // Plugins — same set as authbridge-proxy, plus the cpex plugin + // which lives behind //go:build cpex. The cpex import only fires + // in this binary's build; pure-Go binaries (authbridge-proxy, + // authbridge-envoy, authbridge-lite) don't import it. + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/a2aparser" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/cpex" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/ibac" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/inferenceparser" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/mcpparser" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/sparc" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenbroker" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" +) + +var logLevel = new(slog.LevelVar) + +func initLogging() { + switch strings.ToLower(os.Getenv("LOG_LEVEL")) { + case "debug": + logLevel.Set(slog.LevelDebug) + case "warn": + logLevel.Set(slog.LevelWarn) + case "error": + logLevel.Set(slog.LevelError) + default: + logLevel.Set(slog.LevelInfo) + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))) +} + +func startSignalToggle() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) + go func() { + for range sigCh { + if logLevel.Level() == slog.LevelDebug { + logLevel.Set(slog.LevelInfo) + slog.Info("log level toggled to INFO (send SIGUSR1 to switch back to DEBUG)") + } else { + logLevel.Set(slog.LevelDebug) + slog.Info("log level toggled to DEBUG (send SIGUSR1 to switch back to INFO)") + } + } + }() +} + +func main() { + configPath := flag.String("config", "", "path to config YAML file") + flag.Parse() + + initLogging() + startSignalToggle() + + if *configPath == "" { + log.Fatal("--config is required") + } + + bootCfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("initial config load: %v", err) + } + var provider *spiffe.Provider + if bootCfg.SPIFFE != nil { + mirrorFiles := true + if bootCfg.SPIFFE.MirrorFiles != nil { + mirrorFiles = *bootCfg.SPIFFE.MirrorFiles + } + provider, err = spiffe.NewProvider(context.Background(), spiffe.ProviderConfig{ + SocketPath: bootCfg.SPIFFE.Socket, + MirrorFiles: mirrorFiles, + MirrorDir: bootCfg.SPIFFE.MirrorDir, + }) + if err != nil { + log.Fatalf("spiffe provider: %v", err) + } + defer provider.Close() + } + + buildPipelines := func() (*pipeline.Pipeline, *pipeline.Pipeline, *config.Config, error) { + c, err := config.Load(*configPath) + if err != nil { + return nil, nil, nil, err + } + if c.Mode != "" && c.Mode != config.ModeProxySidecar { + return nil, nil, nil, fmt.Errorf( + "authbridge-cpex supports only mode=%q (got %q); use cmd/authbridge-envoy for envoy-sidecar mode", + config.ModeProxySidecar, c.Mode) + } + c.Mode = config.ModeProxySidecar + config.ApplyPreset(c) + if err := config.Validate(c); err != nil { + return nil, nil, nil, err + } + config.WarnEmptyPipelines(c, slog.Default()) + in, err := plugins.BuildWithSPIFFE(c.Pipeline.Inbound.Plugins, provider) + if err != nil { + return nil, nil, nil, fmt.Errorf("inbound: %w", err) + } + out, err := plugins.BuildWithSPIFFE(c.Pipeline.Outbound.Plugins, provider) + if err != nil { + return nil, nil, nil, fmt.Errorf("outbound: %w", err) + } + return in, out, c, nil + } + + inboundPipeline, outboundPipeline, cfg, err := buildPipelines() + if err != nil { + log.Fatalf("initial pipeline build: %v", err) + } + + initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer initCancel() + if err := inboundPipeline.Start(initCtx); err != nil { + log.Fatalf("inbound pipeline Start: %v", err) + } + if err := outboundPipeline.Start(initCtx); err != nil { + log.Fatalf("outbound pipeline Start: %v", err) + } + + inboundH := pipeline.NewHolder(inboundPipeline) + outboundH := pipeline.NewHolder(outboundPipeline) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + rld := reloader.New(*configPath, inboundH, outboundH, buildPipelines, cfg) + if err := rld.Start(ctx); err != nil { + log.Fatalf("reloader: %v", err) + } + + var sessions *session.Store + if cfg.Session.SessionEnabled() { + ttl := 30 * time.Minute + if cfg.Session.TTL != "" { + if d, err := time.ParseDuration(cfg.Session.TTL); err == nil { + ttl = d + } else { + slog.Warn("invalid session.ttl, using default", "value", cfg.Session.TTL, "error", err) + } + } + maxEvents := 100 + if cfg.Session.MaxEvents > 0 { + maxEvents = cfg.Session.MaxEvents + } + maxSessions := 100 + if cfg.Session.MaxSessions > 0 { + maxSessions = cfg.Session.MaxSessions + } + sessions = session.New(ttl, maxEvents, maxSessions) + slog.Info("session tracking enabled", "ttl", ttl, "maxEvents", maxEvents, "maxSessions", maxSessions) + } else { + slog.Info("session tracking disabled") + } + + var httpServers []*http.Server + + var ( + rpMTLS *reverseproxy.MTLSOptions + fpMTLS *forwardproxy.MTLSOptions + mtlsMetrics *authtls.Metrics + ) + if cfg.MTLS != nil { + if provider == nil { + log.Fatal("mtls requires the spiffe block to be configured") + } + strict := cfg.MTLS.ResolvedMode() == config.MTLSModeStrict + src := provider.X509Source() + mtlsMetrics = authtls.NewMetrics() + rpMTLS = &reverseproxy.MTLSOptions{Source: src, Strict: strict, Metrics: mtlsMetrics} + if strict { + fpMTLS = &forwardproxy.MTLSOptions{Source: src, Metrics: mtlsMetrics} + } + slog.Info("mTLS enabled", "mode", cfg.MTLS.ResolvedMode()) + } else { + slog.Info("mTLS disabled (no mtls block in config)") + } + + rpSrv, err := reverseproxy.NewServer(inboundH, sessions, cfg.Listener.ReverseProxyBackend, rpMTLS) + if err != nil { + log.Fatalf("creating reverse proxy: %v", err) + } + fpSrv, err := forwardproxy.NewServer(outboundH, sessions, fpMTLS) + if err != nil { + log.Fatalf("creating forward proxy: %v", err) + } + sharedStore := shared.New() + defer sharedStore.Close() + rpSrv.Shared = sharedStore + fpSrv.Shared = sharedStore + httpServers = append(httpServers, startReverseProxyServer("reverse-proxy", rpSrv, cfg.Listener.ReverseProxyAddr)) + httpServers = append(httpServers, startHTTPServer("forward-proxy", fpSrv.Handler(), cfg.Listener.ForwardProxyAddr)) + _ = mtlsMetrics + + statsProvider := func() *auth.Stats { + sources := plugins.CollectStats(inboundH.Load()) + sources = append(sources, plugins.CollectStats(outboundH.Load())...) + return auth.MergeStats(sources...) + } + statSrv := startStatServer(cfg, rld.ConfigProvider(), statsProvider, rld.Handler()) + + plugins.WarmCatalog() + + var sessionAPISrv *sessionapi.Server + if cfg.Listener.SessionAPIAddr != "" && sessions != nil { + sessionAPISrv = sessionapi.New( + cfg.Listener.SessionAPIAddr, + sessions, + sessionapi.WithPipelines(inboundH, outboundH), + sessionapi.WithCatalog(sessionapi.PluginsCatalog), + ) + go func() { + slog.Warn("session API listening — UNAUTHENTICATED; contains raw user content; never expose via ingress", + "addr", cfg.Listener.SessionAPIAddr) + if err := sessionAPISrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("session API: %v", err) + } + }() + } + + slog.Info("authbridge-cpex starting", "mode", cfg.Mode, "logLevel", logLevel.Level().String()) + + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { + if name := inboundH.NotReadyPlugin(); name != "" { + http.Error(w, "inbound plugin not ready: "+name, http.StatusServiceUnavailable) + return + } + if name := outboundH.NotReadyPlugin(); name != "" { + http.Error(w, "outbound plugin not ready: "+name, http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + }) + slog.Info("health server listening", "addr", ":9091") + if err := http.ListenAndServe(":9091", mux); err != nil { + slog.Warn("health server failed", "error", err) + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + sig := <-sigCh + slog.Info("shutting down", "signal", sig) + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + for _, srv := range httpServers { + srv.Shutdown(shutdownCtx) + } + statSrv.Shutdown(shutdownCtx) + if sessionAPISrv != nil { + sessionAPISrv.Shutdown(shutdownCtx) + } + + outboundPipeline.Stop(shutdownCtx) + inboundPipeline.Stop(shutdownCtx) + + if sessions != nil { + sessions.Close() + } +} + +func startHTTPServer(name string, handler http.Handler, addr string) *http.Server { + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + slog.Info("HTTP server listening", "name", name, "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("%s serve: %v", name, err) + } + }() + return srv +} + +func startReverseProxyServer(name string, rp *reverseproxy.Server, addr string) *http.Server { + srv := &http.Server{ + Addr: addr, + Handler: rp.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + listener, err := rp.Listen(addr) + if err != nil { + log.Fatalf("%s listen: %v", name, err) + } + go func() { + slog.Info("HTTP server listening", "name", name, "addr", addr, "mtls", rp.MTLSEnabled()) + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Fatalf("%s serve: %v", name, err) + } + }() + return srv +} + +func startStatServer(cfg *config.Config, cfgProvider observe.ConfigProvider, statsProvider observe.StatsProvider, reloadStatus http.Handler) *observe.StatServer { + srv := observe.NewStatServer(cfg.Stats.StatsAddress, cfgProvider, statsProvider, + observe.WithReloadStatus(reloadStatus)) + go func() { + slog.Info("stat server listening", "addr", cfg.Stats.StatsAddress) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("stat server: %v", err) + } + }() + return srv +} diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index 389873436..6b65925c1 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -23,6 +23,7 @@ more AuthBridge capabilities. | **[abctl Walkthrough](weather-agent/demo-with-abctl.md)** | Reference | Watch the AuthBridge plugin pipeline live with the `abctl` TUI | Tooling only | | **[IBAC](ibac/README.md)** | Intermediate | Intent-Based Access Control: LLM judge denies outbound HTTP that doesn't align with the user's recorded intent. Reproduces the email-poison / prompt-injection attack from `huang195/ibac`; chat with the agent through the kagenti UI and see the exfiltration blocked, then `make show-result` for a pipeline-level forensic | UI + kubectl | | **[SPARC (finance)](finance-sparc/README.md)** | Intermediate | SPARC pre-tool reflection: the `sparc` plugin blocks a hallucinated/ungrounded tool argument (an invented transaction id) before it executes and transparently asks the user to clarify, then approves the corrected call. Complements IBAC — SPARC verifies argument grounding, IBAC verifies intent alignment | UI + kubectl | +| **[CPEX Bridge (HR)](hr-cpex/README.md)** | Advanced | CPEX/APL declarative policy: one route chains a coarse APL predicate, an embedded Cedar PDP, RFC 8693 token exchange with a post-check, PII redaction and audit plugins. Same request, different data per caller (Bob sees an SSN, Eve gets it redacted). Self-contained: its own kind cluster + namespace, deployed via `make` rather than operator injection | [kubectl (make)](hr-cpex/README.md#quick-start) | ## Recommended Path @@ -91,6 +92,23 @@ more AuthBridge capabilities. - See inbound JWT validation → protocol parsers → outbound exchange → LLM inference → response, paired live +### CPEX Bridge (HR) — APL Policy Engine +- **Self-contained**: own namespace `cpex-demo`, deployed with plain + `kubectl apply` via `make deploy` (not operator-injected); the + `hr-mcp` backend, Keycloak realm, and chat agent are all vendored +- Runs the `cpex` plugin (the `authbridge-cpex` image) with CPEX's + policy language **APL** as a sidecar in front of an MCP backend +- **Embedded PDP**: APL consults Cedar through a `cedar:` step (the + slot is engine-agnostic by design — OPA, AuthZen, …) +- **Explicit effects**: field redaction on request *and* response, a + PII content guardrail, and structured audit logging — all as policy + steps, not backend code +- **Authorization as action**: RFC 8693 token exchange (`delegate(...)`) + is a first-class policy step, with a post-check that refuses to + forward a token the IdP narrowed below what the call needs +- Seven deterministic curl scenarios plus an interactive LLM chat with + mid-conversation persona switching (Bob / Eve / Alice) + ## Prerequisites All demos require: diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile new file mode 100644 index 000000000..345a0b7e2 --- /dev/null +++ b/authbridge/demos/hr-cpex/Makefile @@ -0,0 +1,114 @@ +# CPEX demo Makefile — kind-cluster flow. +# +# End-to-end: +# +# make deploy # build images, load into kind, kubectl apply +# make port-forward # expose Keycloak (8081) + gateway (8082) on host +# make scenarios # run all 8 demo curl scenarios +# make logs # tail authbridge-cpex logs +# make undeploy # delete the cpex-demo namespace +# +# Prerequisites: +# - kind cluster (we target `kagenti`; override KIND_CLUSTER) +# - docker / podman +# - kubectl +# All source needed for the demo lives in this directory tree +# (hr-mcp-server/, k8s/, agent/). + +KIND_CLUSTER ?= kagenti +NAMESPACE ?= cpex-demo + +# Image tags — local images, loaded into kind, never pushed to a +# registry. imagePullPolicy: Never in the Deployment manifests. +AUTHBRIDGE_IMAGE ?= authbridge-cpex:dev +HR_MCP_IMAGE ?= hr-mcp:dev + +# Source paths for the images. authbridge-cpex builds from the +# authbridge module (cmd/ + authlib/, two levels up from this demo +# dir); hr-mcp is vendored alongside the manifests so the demo is +# self-contained. +AUTHBRIDGE_ROOT := $(abspath $(CURDIR)/../..) +HR_MCP_SRC ?= $(CURDIR)/hr-mcp-server + +.PHONY: help build-images load-images deploy port-forward scenarios logs undeploy preflight + +.DEFAULT_GOAL := help + +help: ## Show this menu (default) + @printf "\nCPEX demo — runs CPEX/APL policy on the kagenti AuthBridge\n" + @printf "platform. Self-contained namespace: $(NAMESPACE).\n\n" + @printf "Typical run:\n" + @printf " \033[1mmake deploy\033[0m\n" + @printf " \033[1mmake port-forward\033[0m (in another terminal)\n" + @printf " \033[1mmake scenarios\033[0m\n\n" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-18s\033[0m %s\n", $$1, $$2}' + +preflight: ## Check that prerequisites are in place + @command -v kind >/dev/null || { echo "missing: kind"; exit 1; } + @command -v kubectl >/dev/null || { echo "missing: kubectl"; exit 1; } + @command -v docker >/dev/null || { echo "missing: docker"; exit 1; } + @kind get clusters | grep -q '^$(KIND_CLUSTER)$$' || { echo "missing kind cluster: $(KIND_CLUSTER)"; exit 1; } + @test -d "$(HR_MCP_SRC)" || { echo "missing: $(HR_MCP_SRC) (should ship in the demo dir)"; exit 1; } + @echo "preflight OK" + +build-images: preflight ## Build authbridge-cpex + hr-mcp docker images + @echo "[build] authbridge-cpex from $(AUTHBRIDGE_ROOT)" + cd $(AUTHBRIDGE_ROOT) && docker build \ + -f cmd/authbridge-cpex/Dockerfile \ + --build-arg CPEX_FFI_VERSION=$$(cat cmd/authbridge-cpex/CPEX_FFI_VERSION | tr -d '[:space:]') \ + -t $(AUTHBRIDGE_IMAGE) . + @echo "[build] hr-mcp from $(HR_MCP_SRC)" + cd $(HR_MCP_SRC) && docker build -t $(HR_MCP_IMAGE) . + +load-images: preflight ## Load both images into the kind cluster + kind load docker-image $(AUTHBRIDGE_IMAGE) --name $(KIND_CLUSTER) + kind load docker-image $(HR_MCP_IMAGE) --name $(KIND_CLUSTER) + +apply: ## kubectl apply the manifests (assumes images already loaded) + kubectl apply -f k8s/00-namespace.yaml + # Recreate the realm ConfigMap from the JSON file each time so + # realm-export.json edits take effect on `make apply`. + kubectl create configmap keycloak-realm \ + --namespace $(NAMESPACE) \ + --from-file=cpex-demo-realm.json=k8s/realm-export.json \ + --dry-run=client -o yaml | kubectl apply -f - + # Same dynamic ConfigMap shape for cpex-policy.yaml so YAML + # edits propagate without rewriting a manifest. + kubectl create configmap cpex-policy \ + --namespace $(NAMESPACE) \ + --from-file=cpex.yaml=k8s/cpex-policy.yaml \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f k8s/10-keycloak.yaml + kubectl apply -f k8s/20-hr-mcp.yaml + kubectl apply -f k8s/30-authbridge-cpex.yaml + +deploy: build-images load-images apply ## Full bring-up: build, load, apply + @echo + @echo "Waiting for pods to become Ready..." + kubectl -n $(NAMESPACE) wait --for=condition=Ready pod -l app.kubernetes.io/part-of=cpex-demo --timeout=120s + @echo + @printf "\033[1;32mDeploy complete.\033[0m Next:\n" + @printf " make port-forward # in another terminal\n" + @printf " make scenarios\n" + +port-forward: ## Forward Keycloak (8081) + gateway (8082) to localhost + @echo "Port-forwarding (Ctrl+C to stop)..." + @echo " Keycloak admin: http://localhost:8081 (admin/admin)" + @echo " Gateway: http://localhost:8082" + kubectl -n $(NAMESPACE) port-forward svc/keycloak 8081:8080 & \ + kubectl -n $(NAMESPACE) port-forward svc/authbridge-cpex 8082:8080 & \ + wait + +scenarios: ## Run all 8 demo curl scenarios (assumes port-forward up) + @for s in $(CURDIR)/scenarios/0[1-8]-*.sh; do \ + GATEWAY=http://localhost:8082 \ + KEYCLOAK_HOST=http://localhost:8081 \ + "$$s"; \ + done + +logs: ## Tail authbridge-cpex logs + kubectl -n $(NAMESPACE) logs -f deploy/authbridge-cpex + +undeploy: ## Tear down the demo + -kubectl delete namespace $(NAMESPACE) --wait=false diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md new file mode 100644 index 000000000..39a0ee786 --- /dev/null +++ b/authbridge/demos/hr-cpex/README.md @@ -0,0 +1,325 @@ +# CPEX Bridge Demo + +An AuthBridge sidecar fronts an MCP backend on a Kagenti kind cluster +and enforces authorization with CPEX/APL. + +## Why CPEX/APL, not just a PDP + +A decision engine like OPA or Cedar answers one question: given this +input, is the action allowed? That verdict still has to be wired into +something. Real authorization resolves identity first, often consults +more than one engine, mints a token for the downstream call, strips +sensitive fields from the payload, and writes an audit record, all in +the right order with the right short-circuits. That orchestration is +normally bespoke code in every gateway. + +APL is the connective layer that makes the orchestration itself +declarative. A policy is a per-resource chain of steps, and those steps +compose three things a plain PDP does not: + +- **Embedded PDPs.** A decision engine is one kind of step, not the + whole policy. This demo consults Cedar through a `cedar:` step; the + same slot is designed to take OPA, AuthZen, or another engine. APL + owns the orchestration around the engine's discrete verdict, running + cheap predicates first and the PDP only for requests that clear them. +- **Explicit effects.** Authorization is more than allow or deny. APL + expresses effects as first-class steps: `redact(...)` rewrites a + field on the wire, `plugin(pii-scan)` runs a content guardrail, + `plugin(audit-log)` records the outcome. The decision and what to do + about it live in the same place. +- **Actions as decisions.** `delegate(...)` mints a downstream-scoped + token (RFC 8693 token exchange) as a policy step, and a post-check + can gate on its result, refusing to forward a token the IdP narrowed + below what the call needs. Issuing credentials becomes part of the + authorization decision rather than a side effect bolted on afterward. + +For AuthBridge, that is the difference between native Go plugins that +each make one fixed decision and an operator-editable policy that +composes identity, any PDP, token exchange, redaction, and audit into a +single declarative chain, shipped as a ConfigMap. + +## What this demo shows + +1. **Same request, different data.** Bob (HR, with `view_ssn`) sees an + employee SSN. Eve (HR, without `view_ssn`) makes the identical call + and gets the record back with the SSN redacted. The redaction is an + APL effect, not backend logic. +2. **A full pattern in one policy.** A single route chains a coarse APL + predicate, a Cedar PDP decision, an RFC 8693 token exchange, a + post-check on the minted token, a PII guardrail, and an audit record. +3. **Policy as data.** The gateway is a generic AuthBridge sidecar. + Every rule lives in `cpex-policy.yaml`; operators ship changes by + updating a ConfigMap, not by releasing code. + +## Prerequisites + +The demo runs inside an existing Kagenti environment. It does not +install Kagenti or create a cluster. You need: + +- A Kagenti kind cluster named `kagenti`. Install it from the + [Kagenti repository][kagenti]. Override `KIND_CLUSTER` in the + Makefile if yours has a different name. +- Docker, to build the two local images. They load into kind and are + never pushed to a registry. +- kubectl, configured for the cluster. + +Everything else is vendored in this directory: the `hr-mcp` backend, +the AuthBridge manifests, and the chat agent. No sibling checkouts +needed. + +The demo creates one namespace, `cpex-demo`. `make undeploy` removes it +cleanly and leaves the rest of the cluster untouched. + +[kagenti]: https://github.com/kagenti/kagenti + +## Quick start + +```bash +make deploy # build images, load into kind, apply manifests, wait for Ready +make port-forward # in another terminal: Keycloak on :8081, gateway on :8082 +make scenarios # run the seven curl scenarios +``` + +`make scenarios` exercises three personas across allow, deny, and +redact paths: + +``` +01-bob-allow.sh PASS SSN visible +02-alice-deny.sh DENY APL require(role.hr) +03-eve-redact.sh PASS SSN redacted in response <- the one to watch +04-alice-internal-allow.sh PASS Cedar permit + token exchange +05-alice-external-cedar-deny.sh DENY Cedar default-deny +06-bob-apl-deny.sh DENY APL team mismatch +07-bob-pii-deny.sh DENY PII validator (PII in the body) +08-bob-taint-deny.sh DENY taint propagation (session touched secrets) +``` + +**Act 4 — taint propagation (scenario 08).** Reading compensation runs +`taint(secret, session)`, attaching the label `secret` to the CPEX +session (persisted in the session store, keyed by the `X-Session-Id` +the caller threads in). The `send_email` policy then refuses external +email from any session carrying that label +(`session.labels contains "secret": deny`). The deny fires **even when +the email body is clean** — it's the *session* that's tainted, not the +content, which is what distinguishes it from scenario 07's content-based +PII deny. Scenario 08 shows all three beats with fresh per-run session +ids: a clean session sends fine (S1 → 200), reading compensation taints +a second session (S2 → 200), and that session can no longer send email +(S3 → 403 `cpex.session_tainted_secret`). + +Scenario 03 is the headline. Bob and Eve send the byte-for-byte same +request, the same backend returns the same record, but Eve's response +comes back without the SSN because the policy redacts it. + +## The personas + +| Persona | Identity | Result | +|---|---|---| +| Bob | HR, `view_ssn` permission | Full compensation record, SSN included | +| Eve | HR, no `view_ssn` | Same record, SSN redacted | +| Alice | Engineering | Denied HR tools; allowed internal repos, denied external | + +## Interactive chat + +The scenarios are deterministic curl runs. For a live demo, drive the +gateway with an LLM agent that switches persona mid-conversation. + +Set up the agent once: + +```bash +cd agent +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Run it against a local Ollama, no API key required: + +```bash +ollama pull llama3.1 +GATEWAY_URL=http://localhost:8082/mcp python chat.py --persona eve +``` + +The agent defaults to `ollama/llama3.1`. Point `--model` at any +litellm-supported provider for stronger tool-calling: + +```bash +GATEWAY_URL=http://localhost:8082/mcp python chat.py --persona bob --model gpt-4o-mini +``` + +In-chat commands: + +``` +switch swap persona and re-mint both tokens (alice, bob, eve) +relogin refresh both tokens if they expire mid-demo +quit exit +``` + +Suggested conversation: + +``` +look up the compensation for EMP-001234, include the SSN + -> 200 OK, SSN included + +switch eve +look up the compensation for EMP-001234, include the SSN + -> 200 OK, response has no SSN field. Same backend, same request, + policy redacted it. + +switch alice +look up the compensation for EMP-001234 + -> denied (cpex.denied). Alice is not HR. + +search the internal repos for web-app + -> 200 OK. Cedar permits: Alice is engineering, the repo is internal. + +search external repos + -> denied (cpex.cedar_default_deny). Cedar refuses the cross-boundary call. +``` + +See `agent/CHAT-WALKTHROUGH.md` for a longer script and talking points. + +## How it works + +The client sends one MCP request with two JWTs: a client token in +`Authorization` and the persona's user token in `X-User-Token`. The +AuthBridge sidecar runs a two-stage pipeline, `mcp-parser -> cpex`, +and the `cpex` plugin evaluates `cpex-policy.yaml` in order. + +``` +chat.py / curl (host) + | POST /mcp + | Authorization: client token -> jwt-client + | X-User-Token: persona token -> jwt-user + v port-forward :8082 -> svc/authbridge-cpex:8080 ++--------------------------------------------------------------+ +| authbridge-cpex pod (namespace: cpex-demo) | +| | +| AuthBridge pipeline: mcp-parser -> cpex | +| mcp-parser: parse JSON-RPC, set mcp.method / mcp.name | +| cpex: run cpex-policy.yaml in order: | +| | +| 1. identity resolve subject + client from the two | +| JWTs, verified against Keycloak JWKS | +| 2. APL policy require(...) -> cedar PDP -> | +| delegate(...) -> post-check | +| 3. plugins pii-scan (deny), audit-log (observe) | +| 4. body redact(args.ssn) / redact(result.ssn) | ++--------------------------------------------------------------+ + | | + | delegate(): RFC 8693 | on allow: + | token exchange | reverse-proxy + v v + Keycloak svc:8080 hr-mcp svc:9100 + realm cpex-demo get_compensation + clients: hr-copilot, cpex-gateway, send_email + workday-api, github-api search_repos, get_directory +``` + +Response-side redaction (`result.ssn`) runs as the reply flows back +out through the pipeline, so an SSN the backend returns unsolicited is +still stripped for a caller without the permission. + +### Authorization patterns + +All authorization lives in the `routes:` block at the bottom of +`cpex-policy.yaml`, one route per tool, each spelling out its own chain. +That block is the story; read it first. Everything above it is the +plugin toolbox the routes pull from. Two routes show the range, from a +flat permission gate to a four-layer chain. + +**Workday flow** (`get_compensation`). The IdP carries every +permission directly on the user token. APL gates on a flat predicate +and redacts the SSN on the wire when the permission is missing: + +``` +require(role.hr) +delegate(workday-oauth, target: workday-api, permissions: [read_compensation]) +plugin(audit-log) +args.ssn -> str | redact(!perm.view_ssn) +result.ssn -> str | redact(!perm.view_ssn) +``` + +Bob has `view_ssn`, so the SSN passes. Eve does not, so the same field +comes back `[REDACTED]` on both the request and the response. + +**GitHub flow** (`search_repos`). Repo access depends on the +relationship between caller and resource, so four layers compose: + +1. **APL coarse gate**: `require(team.engineering | team.security)` +2. **Cedar PDP**: `cedar: read on Repo{ visibility: ${args.visibility} }` +3. **RFC 8693 exchange**: `delegate(github-oauth, target: github-api, permissions: [repo:read:internal])` +4. **Post-check**: `!(delegation.granted.permissions contains 'repo:read:internal'): deny` + +APL handles the cheap predicate, Cedar decides principal-by-resource, +delegation mints an audience-scoped token, and the post-check refuses +to forward a token the IdP narrowed below what the call needs. + +## Editing policy + +`k8s/cpex-policy.yaml` is the source of truth for all authorization +behavior. `make apply` recreates the ConfigMap from it on every run. +After editing: + +```bash +make apply +kubectl -n cpex-demo rollout restart deployment/authbridge-cpex +``` + +`make apply` regenerates the ConfigMap but does not restart the pod, +since the Deployment spec is unchanged. The rollout restart forces a +fresh pod that mounts the new policy. + +## Files + +| Path | Purpose | +|---|---| +| `k8s/00-namespace.yaml` | The `cpex-demo` namespace | +| `k8s/10-keycloak.yaml` | Keycloak Service and Deployment | +| `k8s/20-hr-mcp.yaml` | hr-mcp backend Service and Deployment | +| `k8s/30-authbridge-cpex.yaml` | AuthBridge config, Service, Deployment | +| `k8s/realm-export.json` | Keycloak realm: users, clients, mappers | +| `k8s/cpex-policy.yaml` | CPEX policy: identity, PDP, delegator, validators, audit | +| `hr-mcp-server/` | Vendored FastAPI MCP backend, built into `hr-mcp:dev` | +| `agent/` | Vendored chat agent: `chat.py`, requirements, walkthrough | +| `scenarios/` | The seven curl scenarios run by `make scenarios` | +| `mint-token.sh` | Mints a persona JWT via Keycloak. Used by scenarios and the agent | +| `verify-token-exchange.sh` | Checks RFC 8693 token exchange against the realm | + +## Tear down + +```bash +make undeploy # deletes the cpex-demo namespace +``` + +## Troubleshooting + +- **Rebuilt an image but the pod still runs the old one.** A rebuild + leaves the Deployment spec identical, so `kubectl apply` does not roll + the pod. After `kind load docker-image authbridge-cpex:dev`, force it + with `kubectl -n cpex-demo rollout restart deployment/authbridge-cpex`. +- **Chat agent cannot reach the gateway.** `chat.py` defaults to + `:8090`, but `make port-forward` exposes the gateway on `:8082`. Pass + `GATEWAY_URL=http://localhost:8082/mcp`. +- **Keycloak token issuer.** `KC_HOSTNAME` sets the `iss` claim to + `http://keycloak.cpex-demo:8080`. Tokens minted through the + port-forward at `localhost:8081` still carry that in-cluster issuer, + and the in-cluster gateway validates it as expected. + `KC_HOSTNAME_STRICT=false` lets clients reach Keycloak at any + hostname. +- **Plaintext HTTP to Keycloak.** Every plugin that talks to Keycloak + (jwt-user, jwt-client, workday-oauth, github-oauth) sets + `insecure_http: true`, because the JWKS and token-endpoint validators + reject plaintext URLs by default. Put TLS in front of Keycloak and + remove these flags for any real deployment. + +## Not covered yet + +- **Kagenti operator injection.** Pods deploy via plain `kubectl apply`, + not the operator's sidecar-injection path. Promoting cpex into the + operator's sidecar list is a separate change. +- **abctl TUI.** Would render each Invocation chain visually, including + the deny short-circuit. Needs the per-sub-plugin Invocation work to + land first. +- **Production TLS.** Keycloak runs on plaintext HTTP. Wire a cert via + cert-manager and disable the `insecure_http` flags for real use. diff --git a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md new file mode 100644 index 000000000..57cd4b5ed --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md @@ -0,0 +1,285 @@ +# chat.py — interactive walkthrough + +A cheat-sheet of prompts to type into `chat.py` and what each one should +demonstrate. Read it line-by-line during the demo; the LLM does the rest. + +## Setup (once) + +In one terminal: + +```bash +# from authbridge/demos/hr-cpex +make deploy # builds images, loads to kind, applies manifests +``` + +In a second terminal: + +```bash +make port-forward # exposes Keycloak (:8081) + gateway (:8082) +``` + +In a third terminal (so the audience can see what reaches the backend): + +```bash +kubectl -n cpex-demo logs -f deploy/hr-mcp +``` + +In a third terminal (the demo itself): + +```bash +cd agent +GATEWAY_URL=http://localhost:8082/mcp python chat.py --persona bob +``` + +chat.py writes a banner showing persona + model + gateway URL. +You're now talking to the LLM, which is in front of the CPEX gateway. + +--- + +## Act 1 — WORKDAY FLOW (single-IdP perms on the user token) + +### Bob (HR, has `perm.view_ssn`) — happy path + +```text +Bob: look up the compensation for EMP-001234, include the SSN +``` + +**Expected:** +- LLM invokes `get_compensation(employee_id="EMP-001234", include_ssn=true, ssn=...)` +- Gateway response: **HTTP 200** with the full record (salary, SSN, etc.) +- LLM presents the record naturally: "Jane Smith, $125,000 salary, SSN 123-45-6789, …" + +**In the hr-mcp log:** +- `authorization = Bearer eyJ…` — note the JWT! Decode it and you'll see + `aud: workday-api`, NOT `aud: hr-copilot`. That's the **minted + workday-api token** from the RFC 8693 exchange — bob's original JWT + never reaches the backend. +- `body.params.arguments` shows `ssn=` intact — + he has the perm, so no redact fired. + +--- + +### Switch to Alice (engineer, NO `role.hr`) — APL deny + +```text +> switch alice +Alice: look up the compensation for EMP-001234 +``` + +**Expected:** +- LLM invokes `get_compensation(employee_id="EMP-001234")` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, + `data.violation = "routes.tool:get_compensation.apl.policy[0]"` +- LLM apologizes politely without revealing the violation code + (system prompt tells it to do exactly this) + +**In the hr-mcp log:** +- Nothing. The request **never reached the backend** — `require(role.hr)` + short-circuited at the gateway. Keycloak's `/token` endpoint also + never received a token-exchange call. + +--- + +### Switch to Eve (HR but NO `perm.view_ssn`) — body rewrite + +```text +> switch eve +Eve: look up the compensation for EMP-001234, include the SSN +``` + +**Expected:** +- LLM invokes `get_compensation(employee_id="EMP-001234", include_ssn=true, ssn="…")` +- Gateway response: **HTTP 200** with the record — BUT the LLM sees + `ssn = "[REDACTED]"` instead of the real value +- LLM presents the record with "[REDACTED]" sitting where the SSN should be + +**Where the redact actually fires:** +- The LLM doesn't include `ssn` in args (just `include_ssn=true`), so the + *request-side* `args:` pipeline is a no-op for this call. +- The backend returns the SSN inside `result.content[0].text` (its + JSON-RPC tool result block). +- The *response-side* `result:` pipeline fires — `redact(!perm.view_ssn)` + against `result.ssn` — and rewrites the response body to + `"ssn": "[REDACTED]"` before the gateway forwards it to the LLM. +- The LLM never sees the real SSN. (Caveat: today both the request and + response rewrites are padded with trailing whitespace to match + Content-Length — documented as an upstream gateway issue in + `docs/upstream-issues/01-content-length-on-body-rewrite.md`.) + +--- + +## Act 2 — GITHUB FLOW (Cedar PDP + per-audience IdP mapping) + +### Alice (engineering) — Cedar PERMITS internal repos + +The backend seed has `internal/web-app`, `internal/data-pipeline`, +`internal/auth-service` for the internal side, and +`external/partner-sdk`, `external/marketing-site` for external. The +search uses substring matching, so spell repo names exactly (no +trailing "s"). + +```text +> switch alice +Alice: search the internal repos for anything called web-app +``` + +**Expected:** +- LLM invokes `search_repos(repo_name="web-app", visibility="internal")` +- Gateway response: **HTTP 200** with a `internal/web-app` match +- LLM presents the result naturally + +**Alternative prompts that hit real seed data:** +- "search for `data` in the internal repos" → `internal/data-pipeline` +- "list all the internal repos" → all three matches + +**Under the hood (mention to the audience):** +1. APL gate `require(team.engineering | team.security)` — passes + (Alice is `team.engineering`) +2. Cedar policy `engineering-internal-repos` — permits (engineer + + `resource.visibility == "internal"`) +3. Token exchange to `github-api` audience — mints a token with + `permissions: [repo:read:internal]` (driven by Alice's + `gh_permissions` user attribute via a Keycloak audience mapper) +4. Post-check `delegation.granted.permissions contains 'repo:read:internal'` + — passes +5. Request forwards to the backend with the minted github token + +**In the hr-mcp log:** +- `authorization = Bearer eyJ…` — this token's `aud` is now `github-api` + (not `workday-api` like the bob case). Same gateway, different + audience-scoped token per route. + +--- + +### Alice (engineering) — Cedar DENIES external repos + +```text +Alice: search the external repos for partner-sdk +``` + +(or `"marketing-site"` — either one in the external bucket triggers +the Cedar deny path; the LLM never sees the seed since the call is +short-circuited at the PDP.) + +**Expected:** +- LLM invokes `search_repos(repo_name="partner-sdk", visibility="external")` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, + `data.violation = "cedar.default_deny"` +- LLM apologizes + +**Under the hood:** +- APL gate STILL passes (Alice is engineering) +- Cedar policy `engineering-internal-repos` has `when { resource.visibility + == "internal" }`. The substitution turns `${args.visibility}` into + `"external"`, the when-clause fails, no permit fires → default deny +- **No IdP call**. Cedar denied before delegation ran. + +**Talking point:** this is the *value* of Cedar over flat APL predicates. +A pure `require(team.engineering)` would have permitted both `internal` +and `external`. Cedar's relationship between principal role and resource +attribute is what catches it. + +--- + +### Switch to Bob (HR) — APL fast-path deny on github + +```text +> switch bob +Bob: search the engineering repos for anything +``` + +**Expected:** +- LLM invokes `search_repos(visibility="internal")` (or similar) +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, + `data.violation = "routes.tool:search_repos.apl.policy[0]"` +- LLM apologizes + +**Talking point:** the APL gate denied at the cheapest layer — Bob is +`team.hr`, not `team.engineering` or `team.security`. **Cedar never ran**, +**no IdP round-trip**. This is the demo's "fast-path deny" — cheap +predicates first, expensive PDP / IdP work only for requests that clear them. + +--- + +## Act 3 — PLUGIN FLOW (PII scanner + audit logger) + +### Bob (has `perm.email_send`) — PII scanner denies + +```text +Bob: send an email to alice@corp.com with the subject "FYI" + and the body "Jane's SSN is 555-12-3456 if you need it" +``` + +**Expected:** +- LLM invokes `send_email(to="alice@corp.com", subject="FYI", body="Jane's SSN is 555-12-3456 if you need it")` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, + `data.violation = "pii.detected"` +- LLM apologizes + +**Under the hood:** +1. APL gate `require(perm.email_send)` — passes (Bob has it) +2. `pii-scan` plugin walks `args.body`, hits the SSN regex pattern +3. Plugin denies; gateway short-circuits +4. `audit-log` plugin still fires (it's wired on the same hook with + `read_delegated_tokens` etc.) — observation can't block — and emits + a JSON record describing the denied attempt + +**In the gateway's logs (`kubectl -n cpex-demo logs -f deploy/authbridge-cpex`):** + +```json +{"ts":"2026-…","plugin":"audit-log","source":"cpex-demo-gateway", + "subject":{"id":"","roles":["hr"], …}, + "entity":{"type":"tool","name":"send_email"}, + "tool_call":{"name":"send_email","args":{"to":"alice@corp.com", …}}, + "delegated_tokens":[…]} +``` + +This is the audit-trail story: even denied attempts get a structured +record, no plugin coordination required. + +--- + +## Talking points to weave in + +- **Multi-role identity:** the gateway distinguishes `Authorization` + (the agent's client identity, `azp=hr-copilot`) from `X-User-Token` + (the human user). Two separate JWKS validations against Keycloak. + Decode both tokens to show this if asked. + +- **Tokens at the boundary:** Bob and Eve see different SSN behavior + WITHOUT either of them having to log in differently. The token claims + drive the gateway; the LLM doesn't know about the policy. + +- **Different audiences per route:** Bob's workday call → minted with + `aud: workday-api`. Alice's github call → minted with `aud: github-api`. + Same gateway, same Keycloak client, different routes + produce differently-scoped tokens. The downstream services see ONLY + tokens minted for them — no cross-audience token reuse. + +- **The body never lies:** for Eve, the LLM saw `[REDACTED]`. We could + decode the JWT it sent to confirm the gateway didn't sneak the real + SSN through some side channel. The rewrite is on the wire. + +--- + +## Things to demo if anyone asks "is this real?" + +- Decode `bob`'s token after a tool call — open https://jwt.io and paste + what hr-mcp logged. Show `aud: workday-api`, signed by Keycloak. +- Run `./verify-token-exchange.sh` live — proves RFC 8693 against + the actual Keycloak running in docker. +- Edit the cpex.yaml `require(...)` line and `pkill -HUP` the gateway — + the new policy takes effect immediately (the manager reloads). +- Open `realm-export.json` and show the Keycloak v2 STE setup. Compare + to MCP's `aud` validation requirement in the authorization spec. + +--- + +## When something goes wrong mid-demo + +| Symptom | Fix | +|---|---| +| LLM doesn't call tools, just chats | Try a more directive prompt (`call the get_compensation tool with employee_id…`). Llama 8B forgets tools; switch to 70B. | +| All scenarios return 401 | Keycloak didn't finish importing. `docker compose down -v && docker compose up -d`, wait 30s, re-run `verify-token-exchange.sh`. | +| Gateway response is a Pingora `PrematureBodyEnd` | The body-rewrite pad logic regressed. Body rewrite is the only place this happens. | +| Cedar returns `cedar.default_deny` for an "allow" case | `${args.X}` substitution may have failed — check the gateway log for the resolve error; the bag key it asked for is in the error. | diff --git a/authbridge/demos/hr-cpex/agent/chat.py b/authbridge/demos/hr-cpex/agent/chat.py new file mode 100755 index 000000000..7d47cc0f0 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/chat.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +"""Interactive LLM agent in front of a CPEX-enabled gateway. + +The LLM thinks it's calling `get_compensation` / `send_email` / +`display_compensation` / `get_directory` tools directly. In reality: + + user prompt + ▼ + LLM (litellm-routed: ollama/llama3, gpt-4o-mini, claude-3-7, …) + ▼ tool_call(...) + THIS agent + ▼ POST /mcp with X-User-Token + Authorization + CPEX-enabled gateway + ▼ identity (jwt-user + jwt-client from Keycloak JWKS) + ▼ APL: require(role.hr), redact(args.ssn) when !perm.view_ssn + ▼ delegate(workday-oauth) — RFC 8693 → Keycloak + ▼ forward to upstream (workday-api token, ssn maybe redacted) + Mock HR MCP server + ◄ tool result + LLM + ◄ "Here's the data: …" + +The interesting demo moments: + + * Alice (engineer) asks for compensation → gateway returns an MCP + JSON-RPC error envelope (HTTP 200, code -32001, data.violation = + `routes.tool:get_compensation.apl.policy[0]`). The LLM sees a + tool error and apologizes politely without leaking the violation. + * Bob (HR + view_ssn) asks for SSN → gateway allows + delegates + → backend sees minted workday-api token + intact SSN + * Eve (HR, no view_ssn) asks for SSN → gateway allows + delegates + → backend sees minted token + ssn=`[REDACTED]` (the LLM presents + "[REDACTED]" as if it were the value, which is exactly the + transparent enforcement story) + +Usage: + + pip install -r requirements.txt + + # No API keys required — default points at a local Ollama with + # llama3.1. Install Ollama (https://ollama.com) and `ollama pull + # llama3.1` first. + python chat.py --persona bob + + # Or use any LiteLLM-supported provider via env: + export OPENAI_API_KEY=... + python chat.py --persona bob --model gpt-4o-mini + + export ANTHROPIC_API_KEY=... + python chat.py --persona bob --model anthropic/claude-3-7-sonnet-20250219 + + # IBM watsonx.ai with Meta's Llama (tool-use needs 70B+): + export WATSONX_APIKEY=... + export WATSONX_URL=https://us-south.ml.cloud.ibm.com + export WATSONX_PROJECT_ID=... + python chat.py --persona bob \\ + --model watsonx/meta-llama/llama-3-3-70b-instruct + +Switch personas mid-session with `switch ` — handy for showing +deny → allow → redact in one continuous demo. Type `quit` to exit. +""" + +import argparse +import json +import os +import sys +import uuid +from typing import Any + +import httpx +import litellm +from rich.console import Console +from rich.panel import Panel + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +DEFAULT_MODEL = "ollama/llama3.1" # local, no API key required +DEFAULT_GATEWAY = "http://localhost:8090/mcp" +DEFAULT_KEYCLOAK = "http://localhost:8081" +KEYCLOAK_REALM = "cpex-demo" +KEYCLOAK_CLIENT_ID = "hr-copilot" +KEYCLOAK_CLIENT_SECRET = "hr-copilot-secret" + +PERSONAS: dict[str, dict[str, str]] = { + "alice": { + "name": "Alice Chen", + "title": "Software Engineer", + "color": "cyan", + "description": "Engineer — no role.hr → policy denies HR tools.", + "password": "alice", + }, + "bob": { + "name": "Bob Martinez", + "title": "HR Manager", + "color": "green", + "description": "HR + view_ssn → policy allows + SSN passes through.", + "password": "bob", + }, + "charlie": { + "name": "Charlie Wu", + "title": "Auditor", + "color": "yellow", + "description": "Auditor (no role.hr) — same as Alice for HR tools.", + "password": "charlie", + }, + "eve": { + "name": "Eve Patel", + "title": "HR Coordinator", + "color": "magenta", + "description": "HR but NO view_ssn → policy allows; SSN gets redacted.", + "password": "eve", + }, +} + +SYSTEM_PROMPT = ( + "You are an HR assistant for an HR copilot app. Help the user look up " + "employee compensation, view directories, send emails, and similar " + "tasks. Use the provided tools when needed. " + "\n\n" + "How to interpret tool results: " + "\n" + " * If the tool returns a normal result, present the data to the " + "user. If any field's value is the literal string `[REDACTED]`, " + "show it as-is in your answer — that is the gateway's transparent " + "enforcement marker that the field exists but is hidden for this " + "caller. Do NOT apologize or refuse; just include the field with " + "the value `[REDACTED]`. " + "\n" + " * If the tool returns an `error` envelope (a JSON-RPC error " + "with a `code` and `message`), the gateway denied the call. " + "Acknowledge politely without revealing the internal violation " + "code — the user may not have permission for that operation. " + "\n" + " * If the tool returns an `auth_error`, the request failed at " + "the transport layer. Ask the user to re-authenticate." +) + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_compensation", + "description": ( + "Get compensation data for an employee. Returns salary, " + "bonus, department, and optionally SSN." + ), + "parameters": { + "type": "object", + "properties": { + "employee_id": { + "type": "string", + "description": "Employee identifier (e.g., EMP-001234)", + }, + "include_ssn": { + "type": "boolean", + "description": "Whether to include SSN in the response", + "default": False, + }, + "ssn": { + "type": "string", + "description": ( + "An echo-back of the employee's SSN if the caller " + "claims to already know it — this is exactly the " + "kind of field the gateway redacts when the " + "caller lacks the necessary permission." + ), + }, + }, + "required": ["employee_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "display_compensation", + "description": ( + "Display a compensation summary for the employee (band only, " + "no salary)." + ), + "parameters": { + "type": "object", + "properties": { + "employee_id": {"type": "string"}, + }, + "required": ["employee_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_directory", + "description": "Get the employee directory listing.", + "parameters": { + "type": "object", + "properties": { + "department": { + "type": "string", + "description": "Optional department filter", + "default": "", + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_email", + "description": "Send an email (simulated).", + "parameters": { + "type": "object", + "properties": { + "to": {"type": "string"}, + "subject": {"type": "string"}, + "body": {"type": "string"}, + }, + "required": ["to", "subject", "body"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "search_repos", + "description": ( + "Search the internal GitHub Enterprise for repositories. " + "Filter by name substring and/or visibility. Visibility is " + "one of `internal`, `public`, `external`." + ), + "parameters": { + "type": "object", + "properties": { + "repo_name": { + "type": "string", + "description": "Substring to filter repo names (e.g. 'web-app').", + "default": "", + }, + "visibility": { + "type": "string", + "description": ( + "Repo visibility — `internal` (default), `public`, or `external`. " + "External repos are typically off-limits for engineering." + ), + "enum": ["internal", "public", "external"], + }, + }, + "required": ["visibility"], + }, + }, + }, +] + +# --------------------------------------------------------------------------- +# Keycloak token minting +# --------------------------------------------------------------------------- + + +def keycloak_token(persona: str, keycloak_host: str) -> str: + """Mint a user JWT via Keycloak password grant. Persona name is + both the username and password in the demo realm.""" + info = PERSONAS[persona] + token_endpoint = f"{keycloak_host}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" + resp = httpx.post( + token_endpoint, + data={ + "grant_type": "password", + "client_id": KEYCLOAK_CLIENT_ID, + "client_secret": KEYCLOAK_CLIENT_SECRET, + "username": persona, + "password": info["password"], + "scope": "openid", + }, + timeout=10, + ) + resp.raise_for_status() + return _extract_access_token(resp) + + +def keycloak_client_token(keycloak_host: str) -> str: + """Mint the hr-copilot client's own service-account token (the + `Authorization` header on every gateway call).""" + token_endpoint = f"{keycloak_host}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" + resp = httpx.post( + token_endpoint, + data={ + "grant_type": "client_credentials", + "client_id": KEYCLOAK_CLIENT_ID, + "client_secret": KEYCLOAK_CLIENT_SECRET, + "scope": "openid", + }, + timeout=10, + ) + resp.raise_for_status() + return _extract_access_token(resp) + + +def _extract_access_token(resp: httpx.Response) -> str: + """Pull access_token out of a Keycloak token response. + + Keycloak can return HTTP 200 with an error body (no access_token), + which would make resp.json()["access_token"] raise a bare KeyError + that hides the real cause. Read the body, use .get(), and raise an + httpx.HTTPStatusError carrying the response so the outer handlers + surface a useful message.""" + body = resp.json() + token = body.get("access_token") + if not token: + raise httpx.HTTPStatusError( + f"token response missing access_token: {body!r}", + request=resp.request, + response=resp, + ) + return token + + +# --------------------------------------------------------------------------- +# Gateway client +# --------------------------------------------------------------------------- + + +def new_session_id(persona: str) -> str: + """Generate a fresh CPEX session id for the chat. + + Threaded to the gateway as `X-Session-Id` (CPEX reads it off + Agent.SessionID, tier-0 of its session resolver). A fresh id per chat + launch / persona switch gives each conversation its own session-scoped + CPEX state — most visibly the taint labels: a session that has read + compensation carries the `secret` label and is then refused external + email, while a brand-new session starts clean. The persona prefix is + purely for human-readable logs; the uuid suffix is what guarantees + uniqueness.""" + return f"chat-{persona}-{uuid.uuid4().hex[:8]}" + + +class GatewayClient: + """Calls tools through the CPEX gateway. The agent sends + the client token in `Authorization` (which our jwt-client + resolver reads), the user token in `X-User-Token` (which the + jwt-user resolver reads), and a per-conversation `X-Session-Id` + (which CPEX uses to scope session state such as taint labels).""" + + def __init__(self, gateway_url: str, client_token: str, user_token: str, session_id: str): + self.gateway_url = gateway_url + self.client_token = client_token + self.user_token = user_token + self.session_id = session_id + self._request_id = 0 + + def set_user_token(self, token: str) -> None: + self.user_token = token + + def set_client_token(self, token: str) -> None: + self.client_token = token + + def set_session_id(self, session_id: str) -> None: + self.session_id = session_id + + def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> tuple[int, dict[str, Any]]: + self._request_id += 1 + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + "id": self._request_id, + } + headers = { + "Authorization": f"Bearer {self.client_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "X-User-Token": self.user_token, + "X-Session-Id": self.session_id, + } + resp = httpx.post(self.gateway_url, json=payload, headers=headers, timeout=30) + # Distinguish gateway-policy denies (4xx with body text) from + # downstream tool errors (200 with JSON-RPC error). Only a + # JSON-decode miss falls back to raw text; network/timeout/type + # errors propagate so they aren't masked as a {"text": ...} body. + try: + data = resp.json() + except (json.JSONDecodeError, ValueError): + data = {"text": resp.text} + return resp.status_code, data + + +# --------------------------------------------------------------------------- +# Chat loop +# --------------------------------------------------------------------------- + + +def format_tool_response(status: int, data: dict[str, Any]) -> str: + """Convert the gateway's response into something compact the LLM + can read. Pull text content out of MCP `result.content[].text`. + + Three shapes the gateway can return (per MCP spec): + + * HTTP 200 + `{"result": ...}` — happy path + * HTTP 200 + `{"error": {"code": -32001, "message": ..., "data": {...}}}` + — application-level deny (policy, PDP, + PII, delegation). The LLM should treat + this as a tool-refusal. + * HTTP 401 + plain-text body — transport-level auth failure (JWT + missing / invalid / wrong audience). + Includes `WWW-Authenticate: Bearer`. + """ + if status == 401: + # Auth-level failure — transport problem, not a policy decision. + # Surface enough for the LLM to back off without retrying. + body = data.get("text") if isinstance(data, dict) else str(data) + return json.dumps({"gateway_status": 401, "auth_error": body}) + if status >= 400: + # Other HTTP errors (e.g. 502 from a Pingora upstream failure). + # CPEX puts the violation code in X-Cpex-Violation but + # we don't surface headers up here. Fall back to body. + return json.dumps({"gateway_status": status, "error": data}) + if "error" in data: + # MCP JSON-RPC error envelope — gateway-side deny (policy, PDP, PII, + # delegation). Pass the message and any violation hint through to the + # LLM so it can give the user a sensible refusal. + err = data["error"] + return json.dumps({ + "error": err.get("message", "tool error"), + "violation": (err.get("data") or {}).get("violation"), + }) + result = data.get("result", {}) + content = result.get("content", []) + text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + combined = "".join(text_parts) + return combined or json.dumps(result) + + +def run_chat( + persona: str, + model: str, + gateway_url: str, + keycloak_host: str, +) -> None: + console = Console() + info = PERSONAS[persona] + + try: + user_tok = keycloak_token(persona, keycloak_host) + client_tok = keycloak_client_token(keycloak_host) + except httpx.HTTPError as e: + console.print(f"[red]Failed to mint tokens from {keycloak_host}: {e}[/red]") + console.print( + "[dim]Is Keycloak running? `docker compose up -d` from the demo " + "directory should have brought it up on :8081.[/dim]" + ) + return + + session_id = new_session_id(persona) + gateway = GatewayClient(gateway_url, client_tok, user_tok, session_id) + + console.print() + console.print( + Panel( + f"[bold]{info['name']}[/bold] — {info['title']}\n" + f"[dim]{info['description']}[/dim]\n\n" + f"[dim]Model: {model}[/dim]\n" + f"[dim]Gateway: {gateway_url}[/dim]\n" + f"[dim]Keycloak: {keycloak_host}[/dim]\n" + f"[dim]Session: {session_id}[/dim]", + title="[bold]CPEX HR Demo[/bold]", + border_style=info["color"], + ) + ) + console.print( + "[dim]commands: `quit` to exit; " + "`switch ` to swap personas; " + "`relogin` to mint fresh tokens for the current persona[/dim]\n" + ) + + messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] + + while True: + try: + user_input = console.input(f"[bold {info['color']}]{info['name']}:[/] ").strip() + except (EOFError, KeyboardInterrupt): + console.print("\n[dim]bye[/dim]") + return + + if not user_input: + continue + if user_input.lower() == "quit": + console.print("[dim]bye[/dim]") + return + if user_input.lower() in ("relogin", "reauth"): + # Re-mint both tokens for the current persona. The client + # token (Authorization header) is otherwise minted once at + # startup; after accessTokenLifespan it expires and every + # request fails with auth.token_expired. This is the + # demo-day escape hatch when a pause runs long. + try: + gateway.set_client_token(keycloak_client_token(keycloak_host)) + gateway.set_user_token(keycloak_token(persona, keycloak_host)) + except httpx.HTTPError as e: + console.print(f"[red]re-auth failed: {e}[/red]") + continue + console.print() + console.print( + Panel( + f"Fresh tokens for [bold]{info['name']}[/bold] + the hr-copilot client.", + title="[bold]re-authenticated[/bold]", + border_style="green", + ) + ) + continue + + if user_input.lower().startswith("switch "): + new = user_input.split(" ", 1)[1].strip().lower() + if new not in PERSONAS: + console.print(f"[red]unknown persona '{new}'. valid: {', '.join(PERSONAS)}[/red]") + continue + try: + gateway.set_client_token(keycloak_client_token(keycloak_host)) + gateway.set_user_token(keycloak_token(new, keycloak_host)) + except httpx.HTTPError as e: + console.print(f"[red]failed to mint token for {new}: {e}[/red]") + continue + persona = new + info = PERSONAS[persona] + # Fresh CPEX session for the new persona: switching users starts + # a clean session, so session-scoped state (taint labels, etc.) + # from the previous persona never leaks across the switch. + session_id = new_session_id(persona) + gateway.set_session_id(session_id) + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + console.print() + console.print( + Panel( + f"[bold]{info['name']}[/bold] — {info['title']}\n" + f"[dim]{info['description']}[/dim]\n\n" + f"[dim]Session: {session_id}[/dim]", + title="[bold]switched[/bold]", + border_style=info["color"], + ) + ) + continue + + messages.append({"role": "user", "content": user_input}) + + try: + response = litellm.completion(model=model, messages=messages, tools=TOOLS, tool_choice="auto") + except Exception as e: + console.print(f"[red]LLM error: {e}[/red]") + messages.pop() + continue + + assistant = response.choices[0].message + if not assistant.tool_calls: + text = assistant.content or "(no response)" + console.print(f"[bold]assistant:[/bold] {text}\n") + messages.append({"role": "assistant", "content": text}) + continue + + # Tool-call path. Replay through the gateway, hand the + # results back to the LLM for a final summarization. + messages.append(assistant.model_dump()) + for tc in assistant.tool_calls: + fn = tc.function + try: + args = json.loads(fn.arguments) if isinstance(fn.arguments, str) else fn.arguments + except json.JSONDecodeError: + args = {} + console.print( + f" [dim]→ {fn.name}({json.dumps(args, separators=(',', ':'))})[/dim]" + ) + status, data = gateway.call_tool(fn.name, args) + tool_text = format_tool_response(status, data) + if status >= 400: + console.print(f" [dim]← [red]{status}[/red]: {tool_text}[/dim]") + else: + # Show the full tool result. Earlier versions truncated + # at 200 chars to keep the terminal scannable, but the + # demo punchline is fields like `ssn=[REDACTED]` — we + # need them visible on the wire so the audience can see + # the gateway enforcement, not just trust the LLM saw it. + console.print(f" [dim]← {tool_text}[/dim]") + messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_text}) + + try: + final = litellm.completion(model=model, messages=messages) + text = final.choices[0].message.content or "" + except Exception as e: + text = f"(LLM error summarizing tool results: {e})" + messages.append({"role": "assistant", "content": text}) + console.print(f"[bold]assistant:[/bold] {text}\n") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> int: + p = argparse.ArgumentParser(description="LLM agent in front of CPEX") + p.add_argument( + "--persona", + default="alice", + choices=list(PERSONAS), + help="Starting persona (switch in-session with `switch `)", + ) + p.add_argument( + "--model", + default=os.environ.get("DEMO_MODEL", DEFAULT_MODEL), + help=f"litellm-routed model (default: {DEFAULT_MODEL})", + ) + p.add_argument( + "--gateway", + default=os.environ.get("GATEWAY_URL", DEFAULT_GATEWAY), + help=f"CPEX endpoint (default: {DEFAULT_GATEWAY})", + ) + p.add_argument( + "--keycloak", + default=os.environ.get("KEYCLOAK_HOST", DEFAULT_KEYCLOAK), + help=f"Keycloak host (default: {DEFAULT_KEYCLOAK})", + ) + args = p.parse_args() + run_chat(args.persona, args.model, args.gateway, args.keycloak) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/authbridge/demos/hr-cpex/agent/requirements.txt b/authbridge/demos/hr-cpex/agent/requirements.txt new file mode 100644 index 000000000..4c7e98a93 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/requirements.txt @@ -0,0 +1,18 @@ +# LLM provider abstraction — supports OpenAI, Anthropic, Ollama, +# WatsonX, Azure, Bedrock, and more. Lets the demo run against +# whatever the operator has access to without code changes. +# +# Exact pins (== convention matches hr-mcp-server/requirements.txt) for +# reproducible builds. litellm is pinned at the lowest safe release: +# 1.82.7 / 1.82.8 had a supply-chain compromise (see CVE / GH advisory), +# so 1.83.0 is the floor. rich / httpx pinned to the lowest version that +# satisfied their prior ranges. +litellm==1.83.0 + +# Rich for pretty terminal output (boxed personas, color-coded +# tool calls, etc.). +rich==13.7.0 + +# httpx for the gateway POST. Already pulled transitively by litellm +# but listed explicitly so the dep is visible. +httpx==0.27.0 diff --git a/authbridge/demos/hr-cpex/hr-mcp-server/Dockerfile b/authbridge/demos/hr-cpex/hr-mcp-server/Dockerfile new file mode 100644 index 000000000..4a7ddd012 --- /dev/null +++ b/authbridge/demos/hr-cpex/hr-mcp-server/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py /app/server.py + +EXPOSE 9100 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "9100"] diff --git a/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt b/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt new file mode 100644 index 000000000..0ea2c631a --- /dev/null +++ b/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 diff --git a/authbridge/demos/hr-cpex/hr-mcp-server/server.py b/authbridge/demos/hr-cpex/hr-mcp-server/server.py new file mode 100644 index 000000000..f8a516f28 --- /dev/null +++ b/authbridge/demos/hr-cpex/hr-mcp-server/server.py @@ -0,0 +1,282 @@ +"""HR Demo MCP Server — HTTP/JSON-RPC variant. + +A small FastAPI app that speaks MCP-shaped JSON-RPC over HTTP so a +gateway can route to it. + +The headline log line is intentional: every inbound request prints +the Authorization header (so audiences can see the IdP-minted token, +NOT the user's original IdP JWT) and the parsed tool arguments +(so they can see args.ssn redacted to `[REDACTED]` when policy +fires). + +Run locally: + pip install -r requirements.txt + uvicorn server:app --host 0.0.0.0 --port 9100 + +Endpoint: + POST /mcp — JSON-RPC 2.0 `tools/call` requests + +Tools: + get_compensation, send_email, display_compensation, get_directory +""" + +import json +import logging +import sys +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-7s %(message)s", + handlers=[logging.StreamHandler(sys.stderr)], +) +logger = logging.getLogger("hr-mcp-server") + +app = FastAPI(title="HR Demo MCP Server") + +# --------------------------------------------------------------------------- +# Mock data (copied from sibling-repos/apl-plugins/demo/hr_demo_server.py) +# --------------------------------------------------------------------------- + +EMPLOYEES: dict[str, dict[str, Any]] = { + "EMP-001234": { + "employee_id": "EMP-001234", + "name": "Jane Smith", + "salary": 125000, + "bonus": 15000, + "ssn": "123-45-6789", + "department": "Engineering", + "internal_notes": "Performance review pending, do not disclose", + "email": "jane.smith@corp.com", + "title": "Senior Software Engineer", + }, + "EMP-005678": { + "employee_id": "EMP-005678", + "name": "Bob Johnson", + "salary": 145000, + "bonus": 25000, + "ssn": "234-56-7890", + "department": "Marketing", + "internal_notes": "Promotion candidate Q2", + "email": "bob.johnson@corp.com", + "title": "Marketing Manager", + }, + "EMP-009012": { + "employee_id": "EMP-009012", + "name": "Alice Chen", + "salary": 145000, + "bonus": 20000, + "ssn": "456-78-9012", + "department": "Engineering", + "internal_notes": "Team lead, retention risk", + "email": "alice.chen@corp.com", + "title": "Principal Engineer", + }, +} + +SENT_EMAILS: list[dict[str, Any]] = [] + +# Fake repo fixtures — names + visibility match what Cedar policy +# expects in `args.repo_name` / `args.visibility`. +REPOS: list[dict[str, Any]] = [ + {"name": "internal/web-app", "visibility": "internal", "stars": 24, "language": "TypeScript"}, + {"name": "internal/api-gateway", "visibility": "internal", "stars": 18, "language": "Rust"}, + {"name": "internal/data-pipeline", "visibility": "internal", "stars": 11, "language": "Python"}, + {"name": "public/showcase-site", "visibility": "public", "stars": 2840, "language": "Astro"}, + {"name": "external/partner-sdk", "visibility": "external", "stars": 47, "language": "Go"}, +] + +# --------------------------------------------------------------------------- +# Tool implementations (logic copied from the stdio server) +# --------------------------------------------------------------------------- + + +def tool_get_compensation(args: dict[str, Any]) -> dict[str, Any]: + employee_id = args.get("employee_id", "") + include_ssn = args.get("include_ssn", False) + employee = EMPLOYEES.get(employee_id) + if not employee: + return {"error": f"Employee {employee_id} not found"} + result = { + "employee_id": employee["employee_id"], + "name": employee["name"], + "salary": employee["salary"], + "bonus": employee["bonus"], + "department": employee["department"], + "title": employee["title"], + "internal_notes": employee["internal_notes"], + } + if include_ssn: + # NB: this is the field gateway redaction should have stripped + # before we ever see it. If the upstream actually sees a real + # SSN here, the redact policy didn't fire. + result["ssn"] = employee["ssn"] + return result + + +def tool_send_email(args: dict[str, Any]) -> dict[str, Any]: + email = {"to": args.get("to", ""), "subject": args.get("subject", ""), "body": args.get("body", "")} + SENT_EMAILS.append(email) + return { + "status": "sent", + "message_id": f"msg-{len(SENT_EMAILS):04d}", + "to": email["to"], + "subject": email["subject"], + } + + +def tool_display_compensation(args: dict[str, Any]) -> dict[str, Any]: + employee_id = args.get("employee_id", "") + employee = EMPLOYEES.get(employee_id) + if not employee: + return {"error": f"Employee {employee_id} not found"} + return { + "employee_id": employee["employee_id"], + "name": employee["name"], + "department": employee["department"], + "title": employee["title"], + "salary_band": ( + "senior" if employee["salary"] >= 120000 else + "mid" if employee["salary"] >= 80000 else + "junior" + ), + "has_bonus": employee["bonus"] > 0, + } + + +def tool_get_directory(args: dict[str, Any]) -> list[dict[str, Any]]: + department = args.get("department", "") + entries = [] + for emp in EMPLOYEES.values(): + if department and emp["department"].lower() != department.lower(): + continue + entries.append({ + "name": emp["name"], + "department": emp["department"], + "title": emp["title"], + "email": emp["email"], + }) + return entries + + +def tool_search_repos(args: dict[str, Any]) -> dict[str, Any]: + # Simulated GitHub Enterprise search. The interesting demo + # property: this returns the repo IF the gateway allowed the + # call through. The Cedar policy + post-delegate check + # determines whether the request reaches this code at all. + repo_name = args.get("repo_name", "") + visibility = args.get("visibility", "") + matches = [] + for r in REPOS: + if repo_name and repo_name.lower() not in r["name"].lower(): + continue + if visibility and r["visibility"].lower() != visibility.lower(): + continue + matches.append(r) + return {"matches": matches, "query": {"repo_name": repo_name, "visibility": visibility}} + + +TOOLS = { + "get_compensation": tool_get_compensation, + "send_email": tool_send_email, + "display_compensation": tool_display_compensation, + "get_directory": tool_get_directory, + "search_repos": tool_search_repos, +} + +# --------------------------------------------------------------------------- +# JSON-RPC endpoint +# --------------------------------------------------------------------------- + + +def _redact_token(value: str) -> str: + """Trim long bearer tokens in logs so audiences can see the prefix + without 800 chars of base64 noise.""" + if value.startswith("Bearer ") and len(value) > 50: + return f"{value[:40]}…[{len(value)-40} chars elided]" + return value + + +@app.post("/mcp") +async def mcp_endpoint(request: Request) -> JSONResponse: + body_bytes = await request.body() + + # Demo headline: print what reached us. This is what the audience + # watches to see the gateway's effects. + logger.info("=" * 64) + logger.info("INBOUND REQUEST (this is what reached the MCP server)") + interesting_headers = [ + "authorization", "x-user-token", "x-cpex-violation", + ] + for name in interesting_headers: + v = request.headers.get(name) + if v is not None: + logger.info(" %-25s = %s", name, _redact_token(v)) + try: + rpc = json.loads(body_bytes) + logger.info(" body.method = %s", rpc.get("method")) + params = rpc.get("params", {}) + logger.info(" body.params.name = %s", params.get("name")) + logger.info(" body.params.arguments = %s", json.dumps(params.get("arguments", {}))) + except Exception as e: + logger.warning("body is not JSON-RPC: %s", e) + return JSONResponse( + {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None}, + status_code=400, + ) + + method = rpc.get("method", "") + rpc_id = rpc.get("id") + if method != "tools/call": + # tools/list and others — minimal stub so MCP clients don't + # error during discovery, but the demo never exercises these. + return JSONResponse( + {"jsonrpc": "2.0", "result": {"tools": list(TOOLS.keys())}, "id": rpc_id}, + ) + + tool_name = params.get("name", "") + args = params.get("arguments", {}) or {} + impl = TOOLS.get(tool_name) + if impl is None: + return JSONResponse( + { + "jsonrpc": "2.0", + "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"}, + "id": rpc_id, + }, + status_code=404, + ) + + try: + out = impl(args) + except Exception as e: + logger.exception("tool '%s' failed", tool_name) + return JSONResponse( + { + "jsonrpc": "2.0", + "error": {"code": -32000, "message": str(e)}, + "id": rpc_id, + }, + status_code=500, + ) + + logger.info("OUTBOUND RESPONSE") + logger.info(" tool = %s", tool_name) + logger.info(" payload = %s", json.dumps(out)[:200]) + return JSONResponse( + { + "jsonrpc": "2.0", + "result": { + "content": [{"type": "text", "text": json.dumps(out, indent=2)}] + }, + "id": rpc_id, + }, + ) + + +@app.get("/healthz") +async def healthz() -> dict[str, str]: + return {"status": "ok"} diff --git a/authbridge/demos/hr-cpex/k8s/00-namespace.yaml b/authbridge/demos/hr-cpex/k8s/00-namespace.yaml new file mode 100644 index 000000000..e9adc4ae3 --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/00-namespace.yaml @@ -0,0 +1,8 @@ +# Demo namespace — self-contained from the rest of kagenti so +# `kubectl delete ns cpex-demo` is a clean tear-down. +apiVersion: v1 +kind: Namespace +metadata: + name: cpex-demo + labels: + app.kubernetes.io/part-of: cpex-demo diff --git a/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml new file mode 100644 index 000000000..5f7e5982e --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml @@ -0,0 +1,114 @@ +# Keycloak with the cpex-demo realm imported at boot. +# +# We pin KC_HOSTNAME=localhost so JWT `iss` claims emitted by Keycloak +# read `http://localhost:8081/realms/cpex-demo` — the URL the chat +# agent (running on the host) hits via `kubectl port-forward`. The +# gateway's CPEX/APL identity layer validates the iss string match +# against the same value but fetches JWKS via the cluster-internal +# service DNS (`keycloak.cpex-demo:8080`). Split-horizon, standard +# pattern. +# +# Image: quay.io/keycloak/keycloak:26 — Keycloak 26 lifts the +# `client-credentials` audience claim for service-account tokens +# the way our realm-export's hr-copilot client expects. + +# NOTE: the keycloak-realm ConfigMap is built by `make apply` via +# `kubectl create configmap --from-file=k8s/realm-export.json`. Don't +# inline a placeholder ConfigMap here — its data would race with the +# file-driven one (whoever runs second wins) and Keycloak would fail +# realm import on the placeholder content. + +apiVersion: v1 +kind: Service +metadata: + name: keycloak + namespace: cpex-demo +spec: + selector: + app.kubernetes.io/name: keycloak + ports: + - name: http + port: 8080 + targetPort: 8080 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + namespace: cpex-demo + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/part-of: cpex-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: keycloak + template: + metadata: + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/part-of: cpex-demo + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.6.3 + args: + - "start-dev" + - "--import-realm" + env: + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: "admin" + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + value: "admin" + - name: KC_HTTP_PORT + value: "8080" + # Hostname/issuer URL: cluster DNS. Tokens carry this in + # `iss` regardless of how clients reach the service — + # gateway pods (in-cluster) and the chat agent + # (port-forwarded localhost:8081 → keycloak svc) both + # land on tokens with iss=http://keycloak.cpex-demo:8080/..., + # which is what cpex.yaml verifies. + - name: KC_HOSTNAME + value: "http://keycloak.cpex-demo:8080" + - name: KC_HOSTNAME_STRICT + value: "false" + - name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC + value: "false" + - name: KC_HEALTH_ENABLED + value: "true" + # Standard Token Exchange (V2, RFC 8693) backs the demo's + # cpex-gateway → workday-api / github-api delegate() flow. It + # is GA + enabled by default from Keycloak 26.2 on (feature + # token-exchange-standard:v2) — no KC_FEATURES flag, no + # fine-grained admin permissions. Authorization is per-client: + # the requester (cpex-gateway) has standard.token.exchange.enabled + # in realm-export.json, and the user's subject token carries + # cpex-gateway in its `aud` (the cpex-gateway-as-audience mapper). + # Do NOT enable the legacy `token-exchange` (V1) preview feature + # here — it routes the request down the deprecated FGAP path and + # the exchange is rejected with "Client not allowed to exchange". + ports: + - name: http + containerPort: 8080 + volumeMounts: + - name: realm + mountPath: /opt/keycloak/data/import + readinessProbe: + httpGet: + path: /realms/cpex-demo/.well-known/openid-configuration + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 5 + failureThreshold: 60 + resources: + requests: + cpu: "100m" + memory: "512Mi" + limits: + memory: "1Gi" + volumes: + - name: realm + configMap: + name: keycloak-realm diff --git a/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml b/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml new file mode 100644 index 000000000..8e3ccef62 --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml @@ -0,0 +1,62 @@ +# hr-mcp backend — FastAPI server speaking MCP-shaped JSON-RPC over +# HTTP. Implements get_compensation, send_email, and search_repos +# tools used by the demo personas. +# +# Image is built locally: `docker build -t hr-mcp:dev .` from the +# hr-mcp-server source dir, then `kind load docker-image hr-mcp:dev +# --name kagenti`. The imagePullPolicy: Never below tells the +# kubelet to use the locally-loaded image rather than pull from a +# registry. + +apiVersion: v1 +kind: Service +metadata: + name: hr-mcp + namespace: cpex-demo +spec: + selector: + app.kubernetes.io/name: hr-mcp + ports: + - name: http + port: 9100 + targetPort: 9100 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hr-mcp + namespace: cpex-demo + labels: + app.kubernetes.io/name: hr-mcp + app.kubernetes.io/part-of: cpex-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: hr-mcp + template: + metadata: + labels: + app.kubernetes.io/name: hr-mcp + app.kubernetes.io/part-of: cpex-demo + spec: + containers: + - name: hr-mcp + image: hr-mcp:dev + imagePullPolicy: Never + ports: + - name: http + containerPort: 9100 + # No readiness probe: hr-mcp is a JSON-RPC MCP server with + # no `/` route, and adding a health endpoint requires + # patching the upstream hr-mcp-server source. The pod is + # considered ready once the container starts; sub-second + # FastAPI startup makes this fine for demo use. + + resources: + requests: + cpu: "50m" + memory: "64Mi" + limits: + memory: "256Mi" diff --git a/authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml b/authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml new file mode 100644 index 000000000..87d109faa --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml @@ -0,0 +1,148 @@ +# authbridge-cpex gateway — sidecar-shape Deployment. +# +# The authbridge.yaml ConfigMap below carries the AuthBridge config +# (mode, listeners, pipeline). The cpex-policy ConfigMap (created by +# the Makefile from k8s/cpex-policy.yaml) carries the CPEX runtime +# YAML — what CPEX itself parses. Both mount as files; the AuthBridge +# config references cpex-policy via config_file. +# +# Image: authbridge-cpex:dev — built locally and loaded into kind via +# `kind load docker-image authbridge-cpex:dev --name kagenti`. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: authbridge-config + namespace: cpex-demo +data: + authbridge.yaml: | + mode: proxy-sidecar + listener: + # Reverse proxy fronts hr-mcp inside the cluster. + reverse_proxy_addr: ":8080" + reverse_proxy_backend: "http://hr-mcp.cpex-demo:9100" + forward_proxy_addr: ":8081" + # Session API and stats bind to localhost only — both expose raw + # user content / internal state and have no auth. Reach them via + # `kubectl port-forward` (works against the pod regardless of + # Service ports), never cluster-wide. + session_api_addr: "127.0.0.1:9094" + stats: + stats_address: "127.0.0.1:9093" + session: + enabled: true + ttl: 30m + pipeline: + inbound: + plugins: + # mcp-parser populates pctx.Extensions.MCP from the + # JSON-RPC body; cpex reads from there for CMF dispatch. + - name: mcp-parser + # cpex handles identity, policy, delegation entirely via + # its own sub-plugins (apl.identity.jwt, apl.pdp.cedar, + # apl-delegator-oauth, validator/pii-scan, audit/logger). + # The CPEX-side YAML lives in cpex-policy ConfigMap, + # mounted at /etc/cpex/cpex.yaml. + - name: cpex + config: + hooks: + on_request: + - cmf.tool_pre_invoke + on_response: + - cmf.tool_post_invoke + config_file: /etc/cpex/cpex.yaml + fail_open: false + # Bypass paths get stripped before CPEX evaluation so + # health probes and discovery never pay the FFI cost. + # Bypass hosts default covers keycloak/SPIRE/observability. + outbound: + plugins: [] + +--- +apiVersion: v1 +kind: Service +metadata: + name: authbridge-cpex + namespace: cpex-demo +spec: + selector: + app.kubernetes.io/name: authbridge-cpex + ports: + # Only the reverse proxy is exposed cluster-wide. Session API (9094) + # and stats (9093) bind to localhost in the config above and are + # reached via `kubectl port-forward` against the pod, so they are + # deliberately NOT listed here. + - name: reverse-proxy + port: 8080 + targetPort: 8080 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: authbridge-cpex + namespace: cpex-demo + labels: + app.kubernetes.io/name: authbridge-cpex + app.kubernetes.io/part-of: cpex-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: authbridge-cpex + template: + metadata: + labels: + app.kubernetes.io/name: authbridge-cpex + app.kubernetes.io/part-of: cpex-demo + spec: + containers: + - name: authbridge-cpex + image: authbridge-cpex:dev + imagePullPolicy: Never + args: + - "--config" + - "/etc/authbridge/authbridge.yaml" + env: + - name: LOG_LEVEL + value: "info" + ports: + - name: reverse-proxy + containerPort: 8080 + - name: forward-proxy + containerPort: 8081 + - name: session-api + containerPort: 9094 + - name: stats + containerPort: 9093 + - name: health + containerPort: 9091 + volumeMounts: + - name: authbridge-config + mountPath: /etc/authbridge + readOnly: true + - name: cpex-policy + mountPath: /etc/cpex + readOnly: true + readinessProbe: + # /readyz gates on plugin readiness (ANDs Ready() across + # plugins); /healthz is always 200 and only fits liveness. + httpGet: + path: /readyz + port: 9091 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" + volumes: + - name: authbridge-config + configMap: + name: authbridge-config + - name: cpex-policy + configMap: + name: cpex-policy diff --git a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml new file mode 100644 index 000000000..dfe770f75 --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml @@ -0,0 +1,361 @@ +# CPEX runtime config for the HR demo. +# +# ── How to read this file ─────────────────────────────────────────── +# The `routes:` block at the BOTTOM is the story: three tool routes, +# one per demo act, each spelling out the authorization chain for that +# tool. Read those first. Everything above them is the toolbox the +# routes pull from: +# +# plugins: 2 identities (jwt-user, jwt-client) +# 2 delegators (workday-oauth, github-oauth) +# 2 validators (pii-scan, audit-log) +# global.apl.pdp: 1 Cedar PDP (relationship-based authorization) +# +# routes: get_compensation → ACT 1 · Workday flow +# search_repos → ACT 2 · GitHub flow +# send_email → ACT 3 · PII scanner +# +# Plugins resolve by name + hook + priority, NOT by their order in this +# file, so the grouping below is purely for reading. +# ──────────────────────────────────────────────────────────────────── +# +# Keycloak runs locally at http://localhost:8081. JWTs are RS256-signed +# by the realm's automatically-generated signing key. We point at the +# realm's JWKS URL so the resolver fetches the active public key at +# init time (no manual PEM paste). + +plugin_settings: + routing_enabled: true + +plugins: + # ── IDENTITY ───────────────────────────────────────────────────── + # Two JWT resolvers fire on identity.resolve: the human user's token + # (X-User-Token) and the agent's client token (Authorization). Both + # verify against the realm JWKS. + + # User identity. Carries the human's roles + permissions claims. + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + mode: sequential + priority: 10 + on_error: fail + config: + role: user + header: X-User-Token + trusted_issuers: + - issuer: "http://keycloak.cpex-demo:8080/realms/cpex-demo" + audiences: ["cpex-gateway"] + algorithms: ["RS256"] + decoding_key: + kind: jwks_url + url: "http://keycloak.cpex-demo:8080/realms/cpex-demo/protocol/openid-connect/certs" + # Cluster-internal http is intentional — Keycloak runs + # in the same namespace and isn't TLS-terminated. + insecure_http: true + leeway_seconds: 60 + claim_mapper: standard + + # Client identity. Carries the agent's client_id / azp. + - name: jwt-client + kind: identity/jwt + hooks: [identity.resolve] + mode: sequential + priority: 20 + on_error: fail + config: + role: client + header: Authorization + trusted_issuers: + - issuer: "http://keycloak.cpex-demo:8080/realms/cpex-demo" + audiences: ["cpex-gateway", "account"] + algorithms: ["RS256"] + decoding_key: + kind: jwks_url + url: "http://keycloak.cpex-demo:8080/realms/cpex-demo/protocol/openid-connect/certs" + # Cluster-internal http is intentional — Keycloak runs + # in the same namespace and isn't TLS-terminated. + insecure_http: true + leeway_seconds: 60 + claim_mapper: standard + + # ── DELEGATORS (RFC 8693 token exchange) ───────────────────────── + # Two OAuth delegators, one per downstream audience. A route's + # delegate(...) step names which one to invoke. + + # RFC 8693 token-exchange delegator. Trades the user's hr-copilot + # token for a workday-api-audience-scoped access token. + # + # Pattern 1 (workday flow): the user already carries view_ssn etc. + # as a Keycloak user attribute → claim. The exchange just changes + # the audience; the perms come along for the ride. + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + mode: sequential + priority: 30 + on_error: fail + capabilities: + - read_inbound_credentials + - write_delegated_tokens + config: + token_endpoint: "http://keycloak.cpex-demo:8080/realms/cpex-demo/protocol/openid-connect/token" + # Cluster-internal http to Keycloak — same justification as + # the identity plugins' insecure_http. + insecure_http: true + client_id: "cpex-gateway" + client_secret_source: + kind: literal + secret: "cpex-gateway-secret" + timeout_seconds: 5 + default_outbound_header: "Authorization" + + # Second delegator — same plugin kind, different audience. + # + # Pattern 3 (github flow): the user's inbound token does NOT + # carry github perms. The github-api client in Keycloak has a + # per-audience claim mapper (`gh-permissions-as-scope`) that + # injects `gh_permissions` from the user attribute as the + # minted token's `permissions` claim. The IdP is the source of + # system-specific perm truth; the gateway just asks. + - name: github-oauth + kind: delegator/oauth + hooks: [token.delegate] + mode: sequential + priority: 31 + on_error: fail + capabilities: + - read_inbound_credentials + - write_delegated_tokens + config: + token_endpoint: "http://keycloak.cpex-demo:8080/realms/cpex-demo/protocol/openid-connect/token" + # Cluster-internal http to Keycloak — same justification as + # the identity plugins' insecure_http. + insecure_http: true + client_id: "cpex-gateway" + client_secret_source: + kind: literal + secret: "cpex-gateway-secret" + timeout_seconds: 5 + default_outbound_header: "Authorization" + + # ── VALIDATORS ─────────────────────────────────────────────────── + # pii-scan denies on sensitive patterns; audit-log observes and never + # blocks. Routes invoke them via plugin(pii-scan) / plugin(audit-log). + + # ---------------------------------------------------------------- + # Field-level validator: PII scanner. + # ---------------------------------------------------------------- + # Walks tool/prompt arguments looking for SSN / credit-card / email + # patterns. If found in `deny` mode, refuses the request. Wired on + # `cmf.tool_pre_invoke` so it fires before every tool call. + - name: pii-scan + kind: validator/pii-scan + hooks: [cmf.tool_pre_invoke] + mode: sequential + priority: 25 + on_error: fail + config: + detect: + - { kind: ssn } + - { kind: credit_card } + mode: deny + + # ---------------------------------------------------------------- + # Observation: structured audit logger. + # ---------------------------------------------------------------- + # Emits one JSON line per dispatched request capturing subject, + # client, entity, tool args, and any delegation outcomes. Never + # blocks — observation only. Wired on every CMF entity hook so + # the audit trail covers all tool / prompt / resource traffic. + - name: audit-log + kind: audit/logger + hooks: + - cmf.tool_pre_invoke + - cmf.prompt_pre_invoke + - cmf.resource_pre_fetch + mode: sequential + priority: 90 # fires AFTER policy / delegate so the record + # reflects the final decision + minted tokens + on_error: ignore # observation failures don't halt traffic + capabilities: + - read_subject + - read_client + - read_meta + - read_delegated_tokens + config: + destination: stderr + source: cpex-demo-gateway + +global: + identity: + - jwt-user + - jwt-client + + # ---------------------------------------------------------------- + # Cedar PDP — declarative authorization for relationship-based + # decisions. The Cedar policies live inline below. APL routes + # call into Cedar via `cedar:` steps and translate decisions + # into allow/deny in the broader policy pipeline. + # + # Why Cedar here: pure APL predicates like `require(role.engineer)` + # are great for flat principal attributes. Cedar shines when the + # decision needs to relate principal attributes to resource + # attributes (e.g. "engineers can read repos when the repo's + # visibility is internal"). The two compose naturally — APL handles + # the orchestration around Cedar's discrete decisions. + # ---------------------------------------------------------------- + apl: + pdp: + - kind: cedar-direct + dialect: cedar + policy_text: | + // Engineers can read INTERNAL repos. The relationship + // (principal's role × resource's visibility) is the + // archetypal Cedar shape. + @id("engineering-internal-repos") + permit( + principal, + action == Action::"read", + resource is Repo + ) when { + principal.roles.contains("engineer") && + resource.visibility == "internal" + }; + + // Security team can read ANY repo regardless of visibility. + @id("security-team-any-repo") + permit( + principal, + action == Action::"read", + resource is Repo + ) when { + principal.roles.contains("security") + }; + + // Default-deny otherwise — Cedar's defaults make this + // implicit but spelling it out documents intent. + +routes: + # ════════════════════════════════════════════════════════════════ + # ACT 1 ·WORKDAY FLOW · scenarios 01-bob-allow / 03-eve-redact + # ════════════════════════════════════════════════════════════════ + # Pattern 1 (centralized IdP knows all perms). + # User token carries view_ssn directly; APL gates on a flat + # role/perm predicate + redacts ssn on the wire if missing. + # ---------------------------------------------------------------- + - tool: get_compensation + apl: + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" + # Taint the session: reading compensation marks this session as + # having touched secret data. The label persists in the CPEX + # session store (keyed by Agent.SessionID, threaded from the + # X-Session-Id header) and is checked later by send_email — a + # data-flow control that spans two separate tool calls. See + # ACT 4 / scenario 08. + - "taint(secret, session)" + # Emit a structured audit record AFTER the policy + delegate + # decisions are in. Records who called what, with which scope. + - "plugin(audit-log)" + args: + # `redact(!perm.view_ssn)` fires when the caller LACKS the + # permission. The arg key remains, value becomes "[REDACTED]" — + # downstream tool can see "ssn was here but you can't read it." + ssn: "str | redact(!perm.view_ssn)" + result: + # Symmetric guard on the response side. The tool may include + # the SSN in its result (it does, when include_ssn=true); + # without this rule, a caller with role.hr but NOT + # perm.view_ssn (e.g. eve) would receive it. Redacting on the + # response closes the loop — the LLM-driven flow doesn't even + # require the caller to mention `ssn` in args for the guard + # to fire, because the SSN comes back unsolicited in the + # upstream tool's response. + ssn: "str | redact(!perm.view_ssn)" + + # ════════════════════════════════════════════════════════════════ + # ACT 2 · GITHUB FLOW · scenarios 04-alice-internal / 05-external / 06-bob + # ════════════════════════════════════════════════════════════════ + # Pattern 3 (IdP per-audience mapping + Cedar PDP). + # + # Four authorization layers in one route: + # + # 1. APL gate — cheap predicate. Short-circuits requests + # from users who can't ever read repos. + # 2. Cedar PDP — relationship between principal role and + # resource attributes (repo visibility). + # This is what pure flat predicates struggle + # with and what Cedar shines at. + # 3. Delegation — RFC 8693 to github-api audience. Keycloak's + # per-audience mapper injects gh_permissions + # into the `permissions` claim. + # 4. Post-check — verify the IdP actually granted enough. + # If the IdP narrowed the scope, refuse to + # forward — the token never reaches GitHub. + # ---------------------------------------------------------------- + - tool: search_repos + apl: + policy: + # Layer 1 — coarse APL gate on group membership. + - "require(team.engineering | team.security)" + + # Layer 2 — Cedar fine-grained authorization. The resource + # type+id come from the tool args; the principal is built + # from the user's token automatically (subject + roles). + - cedar: + action: 'Action::"read"' + resource: + type: Repo + id: ${args.repo_name} + attributes: + visibility: ${args.visibility} + + # Layer 3 — Token exchange. Keycloak's github-api mapper + # injects the user's gh_permissions as the `permissions` + # claim on the minted token. + - "delegate(github-oauth, target: github-api, audience: github-api, permissions: [repo:read:internal])" + + # Layer 4 — Verify the IdP granted enough. If the user's + # gh_permissions attribute didn't include repo:read:internal, + # the minted token comes back narrower than asked. Deny here + # rather than forwarding an insufficient token. + - "!(delegation.granted.permissions contains 'repo:read:internal'): deny" + + # Audit every github call. + - "plugin(audit-log)" + + # ════════════════════════════════════════════════════════════════ + # ACT 3 · PII SCANNER · scenario 07-bob-pii-deny + # ════════════════════════════════════════════════════════════════ + # SEND_EMAIL — PII scanner gate. + # + # The interesting flow: when the LLM composes an email body, it + # might include raw PII pulled from earlier tool responses ("Bob's + # SSN is 555-12-3456"). The PII scanner catches that before the + # email goes out. Operators see the deny in real time on stderr + # via the audit logger. + # ---------------------------------------------------------------- + - tool: send_email + apl: + policy: + - "require(perm.email_send)" + # Walks args.body / args.subject / args.to and denies if any + # carries an SSN-like or credit-card-like pattern. + - "plugin(pii-scan)" + # ACT 4 · TAINT PROPAGATION · scenario 08-bob-taint-deny + # Block external email from a session that previously accessed + # secret data (get_compensation taints the session "secret"). + # Placed AFTER pii-scan deliberately: a body carrying PII still + # denies via pii-scan (cpex.pii_detected) regardless of taint; + # this rule catches the subtler case where the body is clean but + # the SESSION is tainted. Denies even without any PII in args. + # `taint(secret, session)` on get_compensation persists "secret" to + # the CPEX session store; on the next request in that session it is + # hydrated into `security.labels`, the attribute key the bag exposes + # for labels (the DSL's `session.labels`/`message.labels` split is not + # yet wired in the pinned FFI — labels surface uniformly under + # `security.labels`). Session scope is what makes it survive to here. + - "security.labels contains \"secret\": deny('external email blocked: this session accessed secret data', 'session_tainted_secret')" + - "plugin(audit-log)" diff --git a/authbridge/demos/hr-cpex/k8s/realm-export.json b/authbridge/demos/hr-cpex/k8s/realm-export.json new file mode 100644 index 000000000..71367bc1f --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/realm-export.json @@ -0,0 +1,372 @@ +{ + "realm": "cpex-demo", + "enabled": true, + "displayName": "CPEX Demo Realm", + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "accessTokenLifespan": 14400, + "ssoSessionIdleTimeout": 14400, + "ssoSessionMaxLifespan": 57600, + "defaultSignatureAlgorithm": "RS256", + + "roles": { + "realm": [ + { "name": "hr", "description": "HR staff" }, + { "name": "engineer", "description": "Engineering staff" }, + { "name": "auditor", "description": "Compliance auditors" } + ] + }, + + "groups": [], + + "users": [ + { + "username": "alice", + "email": "alice@corp.com", + "firstName": "Alice", + "lastName": "Engineer", + "enabled": true, + "emailVerified": true, + "credentials": [ + { "type": "password", "value": "alice", "temporary": false } + ], + "realmRoles": ["engineer"], + "attributes": { + "permissions": ["tool_execute"], + "teams": ["engineering"], + "groups": ["engineering"], + "gh_permissions": ["repo:read:internal"] + } + }, + { + "username": "bob", + "email": "bob@corp.com", + "firstName": "Bob", + "lastName": "HRManager", + "enabled": true, + "emailVerified": true, + "credentials": [ + { "type": "password", "value": "bob", "temporary": false } + ], + "realmRoles": ["hr"], + "attributes": { + "permissions": ["tool_execute", "view_ssn", "pii_access", "email_send"], + "teams": ["hr"], + "groups": ["hr"], + "gh_permissions": [] + } + }, + { + "username": "charlie", + "email": "charlie@corp.com", + "firstName": "Charlie", + "lastName": "Auditor", + "enabled": true, + "emailVerified": true, + "credentials": [ + { "type": "password", "value": "charlie", "temporary": false } + ], + "realmRoles": ["auditor"], + "attributes": { + "permissions": ["tool_execute", "pii_access"], + "teams": ["compliance"], + "groups": ["compliance"], + "gh_permissions": [] + } + }, + { + "username": "eve", + "email": "eve@corp.com", + "firstName": "Eve", + "lastName": "HRWithoutSSN", + "enabled": true, + "emailVerified": true, + "credentials": [ + { "type": "password", "value": "eve", "temporary": false } + ], + "realmRoles": ["hr"], + "attributes": { + "permissions": ["tool_execute"], + "teams": ["hr"], + "groups": ["hr"], + "gh_permissions": [] + } + } + ], + + "clientScopes": [ + { + "name": "read_compensation", + "description": "Scope requested via token-exchange for the workday-api compensation route.", + "protocol": "openid-connect", + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "true" + } + }, + { + "name": "repo:read:internal", + "description": "Scope requested via token-exchange for the github-api search_repos route.", + "protocol": "openid-connect", + "attributes": { + "display.on.consent.screen": "false", + "include.in.token.scope": "true" + } + } + ], + + "clients": [ + { + "clientId": "hr-copilot", + "name": "HR Copilot", + "description": "First-party AI HR copilot. Users authenticate via password grant.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "hr-copilot-secret", + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "redirectUris": ["*"], + "webOrigins": ["*"], + "attributes": { + "access.token.lifespan": "600" + }, + "protocolMappers": [ + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "preferred-username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "permissions", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "permissions", + "claim.name": "permissions", + "jsonType.label": "String", + "multivalued": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "teams", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "teams", + "claim.name": "teams", + "jsonType.label": "String", + "multivalued": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "realm-roles-flat", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "claim.name": "roles", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "groups", + "claim.name": "groups", + "jsonType.label": "String", + "multivalued": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "gh_permissions", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "gh_permissions", + "claim.name": "gh_permissions", + "jsonType.label": "String", + "multivalued": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "cpex-gateway-as-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "cpex-gateway", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "cpex-gateway", + "name": "CPEX Gateway", + "description": "CPEX gateway — RFC 8693 token-exchange client.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "cpex-gateway-secret", + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "redirectUris": [], + "webOrigins": [], + "attributes": { + "access.token.lifespan": "600", + "standard.token.exchange.enabled": "true", + "standard.token.exchange.audiences": "workday-api,github-api" + }, + "optionalClientScopes": ["read_compensation", "repo:read:internal"], + "protocolMappers": [ + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "preferred-username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "workday-api-as-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "workday-api", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "github-api-as-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "github-api", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "github-api", + "name": "GitHub API (downstream audience)", + "description": "Internal GitHub Enterprise — token-exchange audience. Pattern 3 (per-audience mapper).", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "github-api-not-used-but-required", + "bearerOnly": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "protocolMappers": [ + { + "name": "gh-permissions-as-scope", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "gh_permissions", + "claim.name": "permissions", + "jsonType.label": "String", + "multivalued": "true", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + }, + { + "clientId": "workday-api", + "name": "Workday API (downstream audience)", + "description": "Workday API — token-exchange audience for the workday flow.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "workday-api-not-used-but-required", + "bearerOnly": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false + } + ] +} diff --git a/authbridge/demos/hr-cpex/mint-token.sh b/authbridge/demos/hr-cpex/mint-token.sh new file mode 100755 index 000000000..2f22bd717 --- /dev/null +++ b/authbridge/demos/hr-cpex/mint-token.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Mint an access token for a demo persona by hitting Keycloak's +# direct password-grant endpoint. Echoes the raw JWT on stdout. +# +# Usage: +# ./mint-token.sh alice # prints alice's user token +# ./mint-token.sh hr-copilot # prints the hr-copilot client's +# # service-account token (used as +# # the gateway-Authorization) +# +# Personas: +# alice — engineer, role=engineer, perms=[tool_execute] +# bob — HR, role=hr, perms=[tool_execute, view_ssn, pii_access, email_send] +# charlie — auditor, role=auditor, perms=[tool_execute, pii_access] +# eve — HR, role=hr, perms=[tool_execute] (NO view_ssn) +# +# Requires `jq`. Token endpoint defaults to localhost:8081 (the +# docker-compose mapping for Keycloak). + +set -euo pipefail + +KEYCLOAK_HOST="${KEYCLOAK_HOST:-http://localhost:8081}" +REALM="${KEYCLOAK_REALM:-cpex-demo}" +TOKEN_ENDPOINT="${KEYCLOAK_HOST}/realms/${REALM}/protocol/openid-connect/token" + +CLIENT_ID="hr-copilot" +CLIENT_SECRET="hr-copilot-secret" + +persona="${1:?usage: $0 }" + +case "$persona" in + alice|bob|charlie|eve) + response=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "username=$persona" \ + -d "password=$persona" \ + -d "scope=openid") + ;; + hr-copilot) + # Service-account / client_credentials grant — for the + # Authorization header (the client's own identity). + response=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "scope=openid") + ;; + *) + echo "unknown persona: $persona" >&2 + echo "valid: alice bob charlie eve hr-copilot" >&2 + exit 1 + ;; +esac + +if ! token=$(echo "$response" | jq -er '.access_token'); then + echo "ERROR: Keycloak did not return an access_token" >&2 + echo "$response" | jq . >&2 || echo "$response" >&2 + exit 1 +fi + +echo "$token" diff --git a/authbridge/demos/hr-cpex/scenarios/01-bob-allow.sh b/authbridge/demos/hr-cpex/scenarios/01-bob-allow.sh new file mode 100755 index 000000000..31c971925 --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/01-bob-allow.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Bob (HR manager, perm.view_ssn) calls get_compensation. Expected: +# +# * Gateway returns 200 with the tool's response +# * The HR MCP server logs show: +# - Authorization: Bearer +# (NOT bob's user JWT) +# - args.ssn arrived intact (Bob has perm.view_ssn → no redact) +# +# Watch the MCP server with: +# docker compose logs -f hr-mcp + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Bob (HR) → get_compensation (include_ssn=true)" +note "Expected: 200 OK" +note "Expected upstream: Authorization is the IdP-minted workday-api token (NOT bob's user JWT)" +note "Expected upstream: args.ssn intact ('would-be-removed-if-redact-fires')" + +BOB=$(mint bob) +CLIENT=$(mint hr-copilot) + +call_get_compensation "$BOB" "$CLIENT" true diff --git a/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh new file mode 100755 index 000000000..9ac3a1aff --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Alice (engineer, role.engineer) calls get_compensation. Expected: +# +# * Gateway returns HTTP 403 with a JSON error body +# {"error":"cpex.",...,"plugin":"cpex"}. A CPEX +# policy deny is an authorization decision, surfaced by AuthBridge +# as a transport-level 403 (not a 500, not an MCP JSON-RPC envelope) +# * No token exchange happened (Keycloak's /token endpoint +# should NOT receive a token-exchange call for this request) +# * MCP server NEVER sees the call (request short-circuits at policy) + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Alice (engineer) → get_compensation" +note "Expected: HTTP 403, code=cpex.routes_tool_get_compensation_apl_policy_0_" +note "Triggered by: require(role.hr) deny BEFORE delegation runs" +note "Expected upstream: no inbound request (gateway short-circuited)" + +ALICE=$(mint alice) +CLIENT=$(mint hr-copilot) + +call_get_compensation "$ALICE" "$CLIENT" false diff --git a/authbridge/demos/hr-cpex/scenarios/03-eve-redact.sh b/authbridge/demos/hr-cpex/scenarios/03-eve-redact.sh new file mode 100755 index 000000000..f0dff093b --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/03-eve-redact.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Eve (HR, no view_ssn perm) calls get_compensation. Expected: +# +# * Gateway returns 200 (policy passes — Eve is HR) +# * Token exchange happens via Keycloak +# * MCP server logs show args.ssn = "[REDACTED]" — the gateway +# rewrote it before forwarding. The downstream tool never sees +# the original SSN value. +# * Other args (employee_id, include_ssn) pass through unchanged. + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Eve (HR, no view_ssn) → get_compensation" +note "Expected: 200 OK" +note "Expected upstream: Authorization is the IdP-minted workday-api token" +note "Expected upstream: args.ssn = '[REDACTED]' (gateway rewrote the body)" + +EVE=$(mint eve) +CLIENT=$(mint hr-copilot) + +call_get_compensation "$EVE" "$CLIENT" false diff --git a/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh new file mode 100755 index 000000000..4ecce6adb --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Alice (engineering, gh_permissions=[repo:read:internal]) calls +# search_repos for an INTERNAL repo. Expected: +# +# Layer 1 — APL gate `require(group.engineering OR group.security)` +# → passes (Alice is engineering) +# Layer 2 — Cedar policy `engineering-internal-repos` +# → permits (engineer + visibility=internal) +# Layer 3 — Token exchange to github-api +# → Keycloak mints token with permissions=[repo:read:internal] +# (Alice's gh_permissions user attribute → claim mapper) +# Layer 4 — `delegation.granted.permissions contains 'repo:read:internal'` +# → passes +# +# Result: 200, hr-mcp logs show Authorization= +# and the parsed args reach the tool intact. + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Alice (engineering) → search_repos(repo_name='web-app', visibility='internal')" +note "Expected: 200 OK" +note "Expected upstream: Authorization = minted github-api token" +note " permissions claim includes repo:read:internal" + +ALICE=$(mint alice) +CLIENT=$(mint hr-copilot) + +curl -s -X POST "$GATEWAY/mcp" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CLIENT" \ + -H "X-User-Token: $ALICE" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "search_repos", + "arguments": { "repo_name": "web-app", "visibility": "internal" } + } + }' | jq . 2>/dev/null || true diff --git a/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh new file mode 100755 index 000000000..5e17bec7b --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Alice (engineering) asks for an EXTERNAL repo. APL's coarse gate +# passes (she IS in engineering), but Cedar's policy doesn't permit +# engineering on external visibility — only security can read those. +# The denial happens at the gateway BEFORE any IdP call. +# +# Layer 1 — APL gate → passes (team.engineering) +# Layer 2 — Cedar → DENIES (engineering policy when-clause fails: +# resource.visibility == "external", not "internal") +# Layers 3-4 — never reached. No token exchange. GitHub never sees +# the request. +# +# Result: HTTP 403 with JSON error body, code = cpex.cedar_default_deny +# — a CPEX policy deny is surfaced by AuthBridge as a transport-level +# 403 (not a 500, not an MCP JSON-RPC envelope). + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Alice (engineering) → search_repos(visibility='external')" +note "Expected: HTTP 403, code=cpex.cedar_default_deny" +note "Triggered by: Cedar denies — engineering can't read external repos" +note "Expected upstream: no inbound request (gateway short-circuits at PDP)" + +ALICE=$(mint alice) +CLIENT=$(mint hr-copilot) + +curl -s -X POST "$GATEWAY/mcp" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CLIENT" \ + -H "X-User-Token: $ALICE" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "search_repos", + "arguments": { "repo_name": "partner-sdk", "visibility": "external" } + } + }' -i 2>&1 | head -20 diff --git a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh new file mode 100755 index 000000000..050c49239 --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Bob (HR, group=hr) tries to search github repos. APL's coarse +# gate fails immediately — Bob isn't in engineering or security. +# The deny happens BEFORE Cedar runs, before any IdP call. +# +# This shows the "fast path" in the policy: cheap predicates run +# first, expensive PDP / IdP work only happens for requests that +# clear them. +# +# Layer 1 — APL gate `require(team.engineering | team.security)` +# → FAILS (Bob is in team.hr) +# Layers 2-4 — never reached. Cedar never invoked, IdP never +# called, no token-exchange round-trip. +# +# Result: HTTP 403 with JSON error body, code = the cpex.* form of the +# apl.policy step index that failed (cpex.routes_tool_search_repos_apl_policy_0_). + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Bob (HR) → search_repos (gateway short-circuits at the APL gate)" +note "Expected: HTTP 403, code=cpex.routes_tool_search_repos_apl_policy_0_" +note "Triggered by: require(team.engineering | team.security) — Bob is team.hr" +note "Expected: Cedar never runs; IdP never called" + +BOB=$(mint bob) +CLIENT=$(mint hr-copilot) + +curl -s -X POST "$GATEWAY/mcp" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CLIENT" \ + -H "X-User-Token: $BOB" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "search_repos", + "arguments": { "visibility": "internal" } + } + }' -i 2>&1 | head -20 diff --git a/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh b/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh new file mode 100755 index 000000000..3c74fabea --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# +# Bob (HR, perm.email_send) tries to send an email whose body +# carries an SSN-like pattern. APL's coarse `require(perm.email_send)` +# passes (Bob has the perm), but the PII scanner plugin walks the +# args, detects the SSN pattern, and denies the call. +# +# This demonstrates a field-level plugin in action — caught BEFORE +# the email backend is touched. The audit-logger plugin still emits +# its observation record for the deny, so operators see the event. +# +# Expected: +# * HTTP 403 with JSON error body, code = cpex.pii_detected — a CPEX +# policy deny is surfaced by AuthBridge as a transport-level 403 +# (not a 500, not an MCP JSON-RPC envelope) +# * Backend (hr-mcp) NEVER receives the call — the deny is +# enforced at the gateway plugin layer +# * stderr (from the gateway process) shows a JSON audit record +# describing the denied send_email attempt + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +step "Bob (HR + email_send) → send_email with SSN in body" +note "Expected: HTTP 403, code=cpex.pii_detected" +note "Triggered by: pii-scan plugin catches the SSN pattern in args" +note "Expected: audit-log still emits a record describing the deny" +note "Expected upstream: no inbound request (gateway plugin denied)" + +BOB=$(mint bob) +CLIENT=$(mint hr-copilot) + +curl -s -X POST "$GATEWAY/mcp" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CLIENT" \ + -H "X-User-Token: $BOB" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "send_email", + "arguments": { + "to": "external@example.com", + "subject": "compensation update", + "body": "FYI — Jane Smith. Her SSN is 555-12-3456 if you need to update payroll." + } + } + }' -i 2>&1 | head -20 diff --git a/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh b/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh new file mode 100755 index 000000000..b45b13346 --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# +# Taint propagation across two tool calls. Reading compensation taints +# the CPEX session with the label "secret" (get_compensation policy: +# `taint(secret, session)`). The send_email policy then refuses to send +# external email from any session carrying that label +# (`session.labels contains "secret": deny`). The deny fires even when +# the email body is perfectly clean — it's the SESSION that's tainted, +# not the content. This is distinct from scenario 07, where the PII +# scanner denies based on the body's contents. +# +# Sessions are correlated by an explicit X-Session-Id header (threaded +# into CPEX's Agent.SessionID). Each run uses fresh, unique ids so the +# scenario is isolated and reproducible. +# +# Three steps: +# S1 clean session, clean body → 200 (baseline: email works) +# S2 new session, get_compensation → 200 (taints session "secret") +# S3 SAME session as S2, clean body → 403 cpex.session_tainted_secret +# +# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/authbridge-cpex + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +BOB=$(mint bob) +CLIENT=$(mint hr-copilot) + +CLEAN_BODY="Quarterly planning sync moved to Thursday." +SID_CLEAN="taint-demo-clean-$$-${RANDOM}" +SID_TAINT="taint-demo-tainted-$$-${RANDOM}" + +step "S1 · Bob → send_email (untainted session, clean body)" +note "Session: $SID_CLEAN (never touched secret data)" +note "Expected: 200 OK — email allowed (require(perm.email_send) ✓, pii-scan ✓, session clean)" +SESSION_ID="$SID_CLEAN" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY" + +step "S2 · Bob → get_compensation (taints the session)" +note "Session: $SID_TAINT" +note "Expected: 200 OK — and the policy's taint(secret, session) marks this session" +SESSION_ID="$SID_TAINT" call_get_compensation "$BOB" "$CLIENT" true + +step "S3 · Bob → send_email (SAME session as S2, clean body)" +note "Session: $SID_TAINT (now carries label \"secret\" from S2)" +note "Expected: HTTP 403, code=cpex.session_tainted_secret" +note "Denied by the session taint — NOT by pii-scan (the body has no PII)" +note "This is the cross-tool data-flow control: looked at secrets → can't email out" +SESSION_ID="$SID_TAINT" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY" diff --git a/authbridge/demos/hr-cpex/scenarios/_lib.sh b/authbridge/demos/hr-cpex/scenarios/_lib.sh new file mode 100755 index 000000000..9117b45f6 --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/_lib.sh @@ -0,0 +1,114 @@ +# Shared helpers for the scenario scripts. Source from each script: +# +# source "$(dirname "$0")/_lib.sh" + +GATEWAY="${GATEWAY:-http://localhost:8090}" +DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +mint() { + "$DEMO_DIR/mint-token.sh" "$1" +} + +_print_response() { + # Pretty-print: HTTP status + selected headers + body (jq if it parses, + # raw otherwise). Always shows *something* — non-JSON error bodies and + # gateway-emitted violation headers stay visible. + local raw="$1" + local status_line headers body + status_line=$(printf '%s' "$raw" | awk 'NR==1 {sub(/\r$/, ""); print; exit}') + headers=$(printf '%s' "$raw" | awk 'NR>1 && /^\r?$/ {exit} NR>1 {sub(/\r$/, ""); print}') + body=$(printf '%s' "$raw" | awk 'p {print} /^\r?$/ {p=1}') + echo " $status_line" + printf '%s\n' "$headers" | awk 'tolower($0) ~ /^x-cpex|^content-type|^www-authenticate/ {print " " $0}' + if [ -n "$body" ]; then + echo " ---" + if printf '%s' "$body" | jq . >/dev/null 2>&1; then + printf '%s\n' "$body" | jq . | sed 's/^/ /' + else + printf '%s\n' "$body" | sed 's/^/ /' + fi + fi +} + +_post_tool() { + local user_token="$1" client_token="$2" body="$3" + # Thread a CPEX session id when the caller sets SESSION_ID. This lands + # in Agent.SessionID (AuthBridge reads X-Session-Id) so session-scoped + # CPEX state — e.g. taint labels — persists across separate tool calls + # in the same logical session. Unset → no header → unchanged behavior. + local extra=() + [ -n "${SESSION_ID:-}" ] && extra+=(-H "X-Session-Id: $SESSION_ID") + # `${extra[@]+"${extra[@]}"}` expands to nothing when the array is empty + # without tripping `set -u` on bash 3.2 (macOS), where a bare + # `"${extra[@]}"` on an empty array errors "unbound variable". + curl -isS --max-time 10 -X POST "$GATEWAY/mcp" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $client_token" \ + -H "X-User-Token: $user_token" \ + ${extra[@]+"${extra[@]}"} \ + --data "$body" +} + +call_get_compensation() { + local user_token="$1" + local client_token="$2" + local include_ssn="${3:-false}" + local employee_id="${4:-EMP-001234}" + + local body + body=$(cat < +# +# If you see "Client not allowed to exchange" or similar — the +# token-exchange permission on workday-api didn't import correctly. +# Tear down + redo: `docker compose down -v && docker compose up -d`. +# Keycloak needs ~20-30s after start to finish realm import. + +set -euo pipefail + +KEYCLOAK="${KEYCLOAK_HOST:-http://localhost:8081}" +REALM="${KEYCLOAK_REALM:-cpex-demo}" +TOKEN_ENDPOINT="${KEYCLOAK}/realms/${REALM}/protocol/openid-connect/token" + +USER_CLIENT_ID="hr-copilot" +USER_CLIENT_SECRET="hr-copilot-secret" +GATEWAY_CLIENT_ID="cpex-gateway" +GATEWAY_CLIENT_SECRET="cpex-gateway-secret" +AUDIENCE="workday-api" + +red() { printf '\033[31m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +dim() { printf '\033[2m%s\033[0m' "$*"; } + +ok() { printf " %s %-32s %s\n" "$(green ✓)" "$1" "$2"; } +fail() { printf " %s %-32s %s\n" "$(red ✗)" "$1" "$2"; } + +# 1. Mint alice's user token via password grant. +alice_resp=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=$USER_CLIENT_ID" \ + -d "client_secret=$USER_CLIENT_SECRET" \ + -d "username=alice" \ + -d "password=alice" \ + -d "scope=openid") + +alice_token=$(echo "$alice_resp" | jq -r '.access_token // empty') +if [ -z "$alice_token" ]; then + fail "alice mint" "(see error below)" + echo "$alice_resp" | jq . >&2 || echo "$alice_resp" >&2 + exit 1 +fi +ok "alice mint" "OK" + +# 2. Exchange alice's token for workday-api audience as the +# cpex-gateway client. This is exactly the call the +# OAuthDelegator makes from inside the gateway. +exchange_resp=$(curl -s -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -u "${GATEWAY_CLIENT_ID}:${GATEWAY_CLIENT_SECRET}" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ + -d "subject_token=$alice_token" \ + -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ + -d "audience=$AUDIENCE") + +minted=$(echo "$exchange_resp" | jq -r '.access_token // empty') +if [ -z "$minted" ]; then + err=$(echo "$exchange_resp" | jq -r '.error // empty') + desc=$(echo "$exchange_resp" | jq -r '.error_description // empty') + fail "cpex-gateway → workday-api" "${err}: ${desc}" + echo + echo "$(dim 'Full Keycloak response:')" + echo "$exchange_resp" | jq . >&2 || echo "$exchange_resp" >&2 + echo + echo "$(dim 'Common causes:')" + echo "$(dim ' - Realm import didn'\''t finish yet — wait ~30s then retry')" + echo "$(dim ' - Realm imported but authorization-services permission')" + echo "$(dim ' on workday-api didn'\''t pick up. Try:')" + echo "$(dim ' docker compose down -v && docker compose up -d')" + echo "$(dim ' - token-exchange feature not enabled in the running')" + echo "$(dim ' Keycloak (check KC_FEATURES in docker-compose.yml)')" + exit 1 +fi + +# Decode the minted token's payload (middle JWT segment, base64url). +payload=$(echo "$minted" | awk -F. '{print $2}') +# pad the base64 to a multiple of 4 +case $((${#payload} % 4)) in + 2) payload="${payload}==" ;; + 3) payload="${payload}=" ;; +esac +decoded=$(printf "%s" "$payload" | tr '_-' '/+' | base64 -d 2>/dev/null || true) +aud=$(echo "$decoded" | jq -r 'if .aud | type == "array" then .aud[0] else .aud end' 2>/dev/null || echo "?") +sub=$(echo "$decoded" | jq -r '.sub // "?"' 2>/dev/null || echo "?") + +if [ "$aud" = "$AUDIENCE" ]; then + ok "cpex-gateway → workday-api" "OK (aud=$aud)" +else + fail "cpex-gateway → workday-api" "aud mismatch: expected '$AUDIENCE' got '$aud'" + exit 1 +fi + +echo +echo "$(dim 'minted token aud claim:') $aud" +echo "$(dim 'minted token sub claim:') $sub" +echo +echo "$(green 'Token exchange works.') The OAuthDelegator inside the gateway will use the same call shape during the demo scenarios." diff --git a/authbridge/docs/cpex-plugin.md b/authbridge/docs/cpex-plugin.md new file mode 100644 index 000000000..2ea47f9a1 --- /dev/null +++ b/authbridge/docs/cpex-plugin.md @@ -0,0 +1,328 @@ +# cpex plugin + +The `cpex` plugin embeds the [CPEX](https://github.com/contextforge-org/cpex) +runtime inside AuthBridge so operators can drive policy with CPEX's +APL (Attribute Policy Language) DSL — and any of the pre-built CPEX +sub-plugins (Cedar PDP, PII scanner, audit logger, JWT identity, +OAuth delegation) — without writing Go. + +A single AuthBridge plugin instance (`cpex`) wraps an entire CPEX +`PluginManager`. Operators declare which CPEX hook names fire on each +AuthBridge phase via the `hooks` block; CPEX's own YAML defines the +sub-plugins those hooks dispatch to. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ authbridge-cpex (binary built with -tags cpex, links libcpex_ffi)│ +│ │ +│ pipeline: │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ jwt-validation│ │ mcp-parser │→│ cpex (this plugin) │ │ +│ └─────────────┘ └─────────────┘ │ │ │ +│ │ on_request: │ │ +│ │ cmf.tool_pre_invoke ─┼─┼──► CPEX FFI +│ │ on_response: │ │ +│ │ cmf.tool_post_invoke ┼─┼──► CPEX FFI +│ └─────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ CPEX runtime (in-process │ + │ via cgo + libcpex_ffi.a) │ + │ │ + │ ┌─────────┐ ┌─────────┐ │ + │ │ APL │ │ Cedar │ │ + │ │ rules │ │ PDP │ │ + │ └─────────┘ └─────────┘ │ + │ ┌─────────┐ ┌─────────┐ │ + │ │ PII scan│ │ Audit │ │ + │ └─────────┘ │ logger │ │ + │ └─────────┘ │ + └──────────────────────────┘ +``` + +## When to use + +The cpex plugin is the right home for policy that: + +- **Combines multiple decision shapes**. APL's flat predicates + (`require(role.hr)`) compose naturally with Cedar's principal-×-resource + rules and IdP-side delegation; expressing that in a single AuthBridge + Go plugin would mean reimplementing each engine. +- **Needs to be edited by non-developers**. The APL DSL + Cedar + policies live in operator YAML; updates ship via ConfigMap reload + rather than a plugin re-deploy. +- **Composes pre-built CPEX sub-plugins** — PII scanners, audit + loggers, OAuth delegators — instead of writing those from scratch in + Go. + +It is **not** the right home for: + +- Plugins whose only job is one stable Go decision (`jwt-validation`, + `token-exchange`) — those stay as AuthBridge-native plugins. +- Policy that needs to run *before* any parser has classified the + body. cpex requires at least one of `mcp-parser` / + `inference-parser` / `a2a-parser` upstream in the chain. + +## Architecture + +### Build constraint + +The cpex plugin links `libcpex_ffi.a` via cgo. The `authbridge-cpex` +binary (`cmd/authbridge-cpex/`) is the only build target that compiles +the plugin — built with `-tags cpex` and `CGO_ENABLED=1`. Other +binaries (`authbridge-proxy`, `authbridge-envoy`, `authbridge-lite`) +stay pure-Go and never import the plugin. + +The plugin's chassis (config decode, dispatch, Invocation recording) +is tag-free; only the cgo adapter that talks to `cpex.PluginManager` +is `//go:build cpex`. Unit tests run with `CGO_ENABLED=0` against a +fake Manager. + +### Hook chains + +Each AuthBridge phase (`OnRequest`, `OnResponse`) dispatches an +ordered list of CPEX hook names. Hooks fire one at a time; the chain +short-circuits on the first sub-plugin that returns deny. A sub-plugin +that returns `modify` records the Invocation, applies header/label +changes to pctx, and lets the chain continue. CPEX's standard hook +names: + +| Hook | Fires for | Typical sub-plugins | +|---|---|---| +| `cmf.tool_pre_invoke` | MCP tool call about to be forwarded | Cedar PDP, PII scanner, validators | +| `cmf.tool_post_invoke` | MCP tool result returned | Audit logger, post-call delegators | +| `cmf.llm_input` | LLM inference request | Prompt-PII redactors | +| `cmf.llm_output` | LLM inference response | Output redactors, audit | + +### Decision vocabulary + +CPEX sub-plugin verdicts map onto AuthBridge's 5-value +`InvocationAction`: + +| CPEX | AuthBridge | Chain effect | +|---|---|---| +| Allow | `allow` | Continue to next hook | +| Deny | `deny` | **Stop chain**, return `pipeline.Reject` | +| Modify | `modify` | Continue to next hook (mutations applied to pctx) | +| Observe | `observe` | Continue (diagnostics, no flow change) | + +A CPEX-internal failure (FFI panic, runtime error) is distinct from a +policy `deny` — it surfaces as `cpex.error` and respects the +`fail_open` flag (see Configuration). + +## Configuration + +```yaml +plugins: + - name: cpex + config: + # Required to do anything useful: at least one hook per phase. + # An empty hooks block is valid; the phase becomes a no-op. + hooks: + on_request: + - cmf.tool_pre_invoke + on_response: + - cmf.tool_post_invoke + + # CPEX-side APL config (identity, pdp, delegator). Passed + # verbatim to CPEX LoadConfig. + apl: + identity: + jwt: + issuer: "https://keycloak.example.com/realms/prod" + jwks_url: "http://keycloak/realms/prod/protocol/openid-connect/certs" + + pdp: + cedar: + policies: | + @id("hr-can-read-comp") + permit( + principal, + action == Action::"read", + resource is CompensationRecord + ) when { principal.roles.contains("hr") }; + + # CPEX pipelines keyed by hook name. Passed verbatim to CPEX. + pipelines: + cmf.tool_pre_invoke: + - pdp/cedar-direct + - validator/pii-scan + cmf.tool_post_invoke: + - audit/logger + + # Behavior on CPEX-internal errors (NOT on policy deny): + # false (default) — request denied with cpex.error + # true — request continues, error logged + fail_open: false + + # CPEX tokio worker pool size. 0 = automatic (CPU count). + worker_threads: 2 + + # Hosts that skip CPEX entirely (default: keycloak/SPIRE/observability). + bypass_hosts: ["keycloak.*", "spire-agent.*"] + + # URL paths that skip CPEX (default: /healthz, /readyz, /livez, /.well-known/*). + bypass_paths: ["/healthz", "/readyz"] +``` + +### Pipeline composition + +cpex declares `RequiresAny: [mcp-parser, inference-parser, a2a-parser]`. +At least one must appear earlier in the chain so the parser has populated +`pctx.Extensions.MCP` / `.Inference` / `.A2A` before cpex extracts CMF +content. `Pipeline.Build` rejects misordered chains at boot. + +cpex also declares `ReadsBody: true, WritesBody: true`. Only one +`WritesBody` plugin is permitted per direction; chaining cpex with +another mutator (e.g. an inline transformer) will fail at boot. + +A typical inbound chain: + +```yaml +plugins: + - name: jwt-validation # identity gate + - name: mcp-parser # populate pctx.Extensions.MCP + - name: cpex # APL/Cedar/PII via CPEX +``` + +## Status codes & reasons + +| Code | HTTP | Meaning | +|---|---|---| +| `cpex.error` | 502 | CPEX FFI returned an error and `fail_open: false`. Body carries the underlying error message. | +| `cpex.denied` | 403 | A CPEX sub-plugin returned deny without a violation code. | +| `cpex.` | 403 | A CPEX sub-plugin returned deny with a violation code; the `` part lower-cases letters, replaces other characters with underscores, e.g. `pii.detected` → `cpex.pii_detected`. | + +The `Invocation.Reason` field carries the CPEX-side reason string for +all of the above so operator dashboards can render the original +sub-plugin message alongside the namespaced code. + +**HTTP status.** A policy `deny` is an authorization decision and +renders as **403 Forbidden**; a CPEX-internal error (fail-closed) +renders as **502 Bad Gateway** (the policy engine itself failed — an +upstream fault, not a client error). The plugin sets both statuses +explicitly: the namespaced `cpex.*` codes are dynamic and never appear +in the listener's static `codeToStatus` table, so without an explicit +status `Violation.Render` would default every CPEX outcome to 500. + +## Bypass list curation + +`bypass_hosts` and `bypass_paths` short-circuit *before* the FFI call, +so traffic to infrastructure that shouldn't see CPEX policy (Keycloak, +SPIRE, observability) skips the cgo round-trip entirely. + +Both lists are operator-extensible glob patterns (`path.Match` syntax). +Default `bypass_hosts` covers `keycloak.*`, `spire-server.*`, +`spire-agent.*`, `otel-collector.*`, `jaeger.*`, `prometheus.*`, +matching the IBAC plugin's default set. Default `bypass_paths` covers +`/healthz`, `/readyz`, `/livez`, `/.well-known/*` via +`authlib/bypass.DefaultPatterns` — the same set jwt-validation uses. + +Patterns that match everything (`"*"`, `""`, `"/*"`) are rejected at +Configure time; that gesture is better expressed by removing the cpex +plugin from the pipeline. + +## fail_open: when to use it + +`fail_open: false` (default) treats a CPEX-internal failure as a +deny. This is the right choice when CPEX is the only enforcement layer +for the traffic class — silently allowing requests when the policy +engine is broken is rarely what operators want. + +`fail_open: true` allows requests through when CPEX errors, logging +the failure. Use this only when: + +- Another enforcement layer (Envoy ext_authz, an upstream gateway) + will gate the same traffic, and cpex is observation-only; OR +- The cost of brief unavailability outweighs the cost of brief + unenforced traffic during a known-recoverable CPEX issue. + +The `Invocation.Reason` field captures the underlying error in both +modes so the failure is auditable. + +## Custom extension passthrough + +Operators upstream of cpex can stash policy-input blobs in +`pctx.Extensions.Custom` under the `cpex/` prefix; cpex forwards them +into the CPEX `Extensions.Custom` map with the `cpex/` prefix +stripped. CPEX sub-plugins read them at the bare key. + +```go +// In an upstream plugin's OnRequest: +pctx.Extensions.Custom["cpex/tenant_tier"] = "enterprise" + +// On the CPEX side, an APL predicate sees it as: +// custom.tenant_tier == "enterprise" +``` + +Entries without the `cpex/` prefix stay private to the plugin that +set them (rate-limiter cookies, plugin-internal state) and never +cross the FFI boundary. + +## Sensitive headers + +A fixed allowlist of sensitive header prefixes and exact names is +stripped from the CMF payload before crossing the FFI boundary — +CPEX sub-plugins (notably `audit/logger`) frequently log the payload +they receive, and the AuthBridge session API has no auth on it. + +Stripped prefixes: `authorization`, `cookie`, `set-cookie`, +`proxy-authorization`, `x-amz-security-token`. + +Stripped exact names: `x-api-key`, `x-auth-token`, `x-authorization`, +`x-secret-token`, `x-session-token`, `x-csrf-token`, +`x-platform-secret`, `x-authbridge-secret`. + +This list is conservative; adding a header here is safer than +discovering one in an audit log. + +## Background tasks + +CPEX sub-plugins that emit asynchronous work (the canonical case is +`audit/logger` writing to an external sink) return their results +through `BackgroundTasks`. The cpex plugin spawns a goroutine per +Invoke that awaits the background work and routes per-sub-plugin +errors through the default `slog` handler with the request ID. + +Operators pipe `slog` output to their observability stack. There is no +configurable audit sink today; future work may add one if operators +need to route background results separately from foreground logs. + +## Limitations + +- **Body modifications aren't yet re-serialized.** When a CPEX + sub-plugin returns `modify` with a body payload change (the PII + scanner's redaction path is the canonical case), the cpex plugin + applies header and label changes to pctx but logs a warning and + leaves `pctx.Body` unchanged. Format-aware re-serialization + (CMF → JSON-RPC / OpenAI) is on the roadmap. +- **Per-sub-plugin Invocations** require the CPEX FFI to expose + per-sub-plugin outcomes; today CPEX returns an aggregate + `PipelineResult` with a single `Violation`. The cpex plugin emits + one aggregate Invocation per Invoke as a result. A CPEX-side + change to expose `PipelineResult.SubPlugins[]` will let the + AuthBridge plugin emit one Invocation per sub-plugin. +- **A2A traffic** is not auto-classified into a CPEX hook. Operators + configure a chain explicitly via `hooks.on_request: []` + even for A2A; CPEX's own routing handles per-sub-plugin filtering. + +## Failure modes (detailed) + +| Symptom | Cause | Fix | +|---|---|---| +| Pipeline build fails at boot: `cpex plugin: this binary was not built with -tags cpex` | Operator pointed `authbridge-proxy` (no cgo) at a config containing a `cpex` plugin. | Use the `authbridge-cpex` image instead; or rebuild with `-tags cpex` against a downloaded `libcpex_ffi.a`. | +| Pipeline build fails: `cpex bypass_paths: invalid bypass pattern` | An entry in `bypass_paths` has bad `path.Match` syntax. | Fix the glob pattern (`/api/*/v1`, `/static/**` etc.). | +| Pipeline build fails: `cpex config: pattern "*" matches everything` | Operator wrote a wildcard pattern in `bypass_hosts` or `bypass_paths`. | If you want to disable cpex, remove it from the pipeline; don't bypass everything. | +| All traffic returns 502 with `cpex.error`, JWKS errors in logs | CPEX's APL identity plugin can't reach Keycloak. | Check the `apl.identity.jwt.jwks_url` is reachable from inside the pod; check the cpex bypass list doesn't include Keycloak (it should). | +| Audit logger silently produces no records | `BackgroundTasks` goroutine started but the audit sink is unreachable. | Search the slog stream for `cpex: background sub-plugin error` — the request ID and elapsed time correlate the failure with the foreground request. | + +## See also + +- `cmd/authbridge-cpex/README.md` — binary build + deployment. +- `cmd/authbridge-cpex/CPEX_FFI_VERSION` — pinned CPEX FFI ABI version + the binary was built against. +- [CPEX repository](https://github.com/contextforge-org/cpex) — APL + DSL, sub-plugin reference, FFI ABI. +- `demos/hr-cpex/` — runnable demo configurations. diff --git a/authbridge/go.work b/authbridge/go.work index b571df7ab..01c4a1002 100644 --- a/authbridge/go.work +++ b/authbridge/go.work @@ -1,8 +1,9 @@ -go 1.25.0 +go 1.25.4 use ( ./authlib ./cmd/abctl + ./cmd/authbridge-cpex ./cmd/authbridge-envoy ./cmd/authbridge-lite ./cmd/authbridge-proxy diff --git a/docs/proposals/authbridge-hooks.md b/docs/proposals/authbridge-hooks.md new file mode 100644 index 000000000..a7ea7d669 --- /dev/null +++ b/docs/proposals/authbridge-hooks.md @@ -0,0 +1,1459 @@ +# AuthBridge Hook System and CPEX Integration + +**Status**: Draft +**Date**: April 2026 + +This document specifies the hook system and plugin runtime for AuthBridge. Hooks provide typed, capability-gated extension points at well-defined stages of the inbound JWT validation and outbound token exchange pipelines. The plugin runtime is built on [CPEX](https://github.com/contextforge-org/contextforge-plugins-framework/tree/main), embedded in-process via Go bindings to the Rust core. + +### How it works + +AuthBridge defines **hooks** (named extension points at each stage of inbound and outbound processing) and their **typed payloads** (the data available at each hook). At startup, AuthBridge imports and initializes a CPEX `PluginManager`, which loads configured plugins and registers their hook subscriptions. On each request, AuthBridge constructs the payload and extensions at each hook point and hands them to CPEX. CPEX handles plugin dispatch (phase ordering, parallel execution, capability gating) and returns an aggregate `PluginResult`. AuthBridge then applies the result, enforcing tighten-only composition, short-circuit behavior, and error handling. + +In short: **CPEX provides the hook system API, plugin runtime, and policy composition model. AuthBridge uses CPEX to define its hooks and payloads, and owns the enforcement of results.** For the detailed responsibility split, see [§7.5: What CPEX provides vs. what AuthBridge provides](#75-what-cpex-provides-vs-what-authbridge-provides). + +**Request flow (inbound example):** + +```mermaid +sequenceDiagram + autonumber + participant Req as Inbound Request + participant AB as AuthBridge + participant Core as CPEX Core
"PluginManager" + participant JWT as jwt-validation
"plugin (builtin)" + participant SP as scope-policy
"plugin" + participant AL as audit-log
"plugin" + + Req->>AB: HandleInbound(authHeader, path, host) + AB->>AB: bypass check + AB->>AB: build payload + extensions (token in Security.Token) + + Note over AB,Core: hook: inbound.pre_validation + AB->>Core: invoke(inbound.pre_validation, payload, extensions) + Core->>JWT: run (sequential, priority 1) + JWT->>JWT: verify JWT signature, issuer, audience + JWT-->>Core: continue (writes Security.Subject with claims) + Core->>SP: run (sequential, priority 10) + SP->>SP: check required scopes for path + SP-->>Core: violation("missing admin scope") + Core->>Core: merge results (deny) + Core-->>AB: PluginResult{continue_processing: false, violation: ...} + + AB->>AB: tighten-only enforcement (deny wins) + + Note over AB,Core: hook: inbound.completed + AB->>Core: invoke(inbound.completed, payload, extensions) + Core->>AL: run (fire_and_forget, background) + Note over AL: logs outcome asynchronously + Core-->>AB: PluginResult{continue_processing: true} + + AB-->>Req: deny (403, "missing admin scope") +``` + +## Table of Contents + +1. [Introduction](#1-introduction) +2. [CPEX Terminology](#2-cpex-terminology) +3. [Hook Catalog](#3-hook-catalog) +4. [Hook Payloads](#4-hook-payloads) +5. [Dispatch Semantics](#5-dispatch-semantics) +6. [Configuration](#6-configuration) +7. [Integration Architecture](#7-integration-architecture) +8. [Built-in Plugin Migration](#8-built-in-plugin-migration) +9. [Adapter Layer](#9-adapter-layer) +10. [Security Invariants and Hook Mapping](#10-security-invariants-and-hook-mapping) +11. [Observability](#11-observability) +12. [Staged Rollout](#12-staged-rollout) +13. [Layering Model and Protocol-Semantic Hooks](#13-layering-model-and-protocol-semantic-hooks) +14. [Open Questions](#14-open-questions) +15. [Appendices](#appendices) + +Out of scope: + +- CPEX internals (payload dispatch, capability gating, plugin executor). See the [CPEX spec](https://github.com/contextforge-org/contextforge-plugins-framework/blob/main/docs/specs/plugin-framework-spec.md) and [CPEX Go API](https://github.com/contextforge-org/contextforge-plugins-framework/blob/docs/golang_proposal/docs/proposals/cpex-golang-bindings-proposal.md). +- Protocol-semantic hooks for MCP, A2A, or LLM payloads. [How protocol-semantic hooks ship later](#133-how-protocol-semantic-hooks-ship-later) explains why they are deferred. +- Hot-swapping plugins at runtime. Plugins are loaded at startup and released on shutdown. + +## 1. Introduction + +### 1.1 Goals + +- **Fill gaps in existing interfaces.** The `Verifier`, `ClientAuth`, and `ActorTokenSource` interfaces handle their specific concerns well. They don't cover cross-cutting observation (audit, metrics), policy enforcement at stages they don't expose (post-bypass, pre-exchange), or out-of-process dispatch. Hooks fill those gaps. +- **Share a CPEX runtime.** Plugins targeting AuthBridge hooks can also target Praxis and ContextForge where payload types overlap. A JWT validation plugin written once runs in all three hosts. +- **Stay within latency budget.** Observation hooks < 1 ms; validate hooks < 5 ms per request. Native plugin overhead stays in single-digit microseconds per hook. See [Failure modes and timeouts](#53-failure-modes-and-timeouts). +- **Tighten-only composition.** Plugins may strengthen any security boundary. They cannot silently weaken any. See [Tighten-only composition](#54-tighten-only-composition). +- **Zero cost when unused.** Deployments without a `plugins:` section pay a single nil check per call site. No dispatcher traversal, no allocations, no MessagePack serialization. +- **Ecosystem access.** AuthBridge gains access to the CPEX plugin ecosystem (identity resolver, PII guard, rate limiter, audit logger, token delegation, APL policy evaluator), all pre-built and shared across hosts. + +### 1.2 Non-Goals + +- **Replacing existing auth logic.** `HandleInbound()` and `HandleOutbound()` remain the orchestration layer. Hooks wrap the pipeline, not the other way around. +- **Hot reload.** Startup-only loading keeps the trust model simple. Reload-without-restart is deferred. +- **Arbitrary cross-hook ordering.** Within a hook point, plugins run in CPEX phase order at configured priority. Across hook points, order follows the request lifecycle. +- **Semantic payload parsing.** MCP, A2A, and LLM protocol parsing should not be implemented in the auth layer. See [Layering Model](#13-layering-model-and-protocol-semantic-hooks). + +## 2. CPEX Terminology + +This section defines CPEX concepts used throughout this spec. For full details, see the [CPEX Plugin Framework Spec](https://github.com/contextforge-org/contextforge-plugins-framework/blob/main/docs/specs/plugin-framework-spec.md) and the [CPEX Go API](https://github.com/contextforge-org/contextforge-plugins-framework/blob/docs/golang_proposal/docs/proposals/cpex-golang-bindings-proposal.md). + +| Term | Definition | +|---|---| +| **PluginMode** | Execution phase for a plugin subscription: `sequential` (serial, can block + modify), `transform` (serial, modify only), `audit` (serial, read-only), `concurrent` (parallel, can block), `fire_and_forget` (background, non-blocking). Configured per subscription in YAML. | +| **Extensions** | Typed metadata attached to a payload: `Security`, `Http`, `Meta`, `Delegation`, `Custom`, etc. Each plugin sees only the extensions its declared capabilities grant. | +| **PluginResult** | Aggregate result from a hook invocation: `continue_processing` (allow), `modified_payload` (mutated copy), or `violation` (deny with reason). | +| **PluginViolation** | Structured denial: `reason`, `description`, `code`, `details`. Carried in `PluginResult.violation`. | +| **ContextTable** | Per-plugin state that persists across hook invocations within a single request. Passed between hook calls to preserve plugin-local data. | +| **BackgroundTasks** | Fire-and-forget async work spawned by plugins. Runs after the hook pipeline completes, outside the request's latency budget. | +| **PluginManager** | CPEX lifecycle manager: loads plugins, registers hook subscriptions, dispatches invocations, and handles shutdown. | +| **OnError** | Per-plugin error handling: `fail` (halt pipeline), `ignore` (log and continue), `disable` (log, disable plugin, continue). | +| **Capability** | A declared permission (e.g., `read_security`, `read_token`, `write_http`) that controls which Extensions a plugin can access or modify. | + +The hook catalog uses three shorthand labels for mode: + +| Label | Meaning | Allowed `PluginMode` values | +|---|---|---| +| **observe** | Read-only, cannot block or modify | `audit`, `fire_and_forget` | +| **validate** | Can block (deny) but cannot modify the payload | `sequential`, `concurrent` | +| **modify** | Can modify the payload via copy-on-write | `transform` | + +## 3. Hook Catalog + +### 3.1 Conventions + +- **ID** is a stable short identifier: `S` startup, `I` inbound, `O` outbound, `A` audit. IDs are stable across the API lifetime; payload types evolve under semver. +- **Name** is the string CPEX registers and the value operators write in YAML `subscribe:` blocks. +- **Mode** is the CPEX plugin mode: `observe`, `validate`, or `modify`. See [CPEX Terminology](#2-cpex-terminology). Where an observe hook runs in the background (fire-and-forget), it is noted as `observe (background)`. +- **Use Cases** lists concrete things you'd build at this hook point. + +### 3.2 Startup hooks + +| ID | Name | Call site | Mode | Use Cases | +|---|---|---|---|---| +| S1 | `startup.config_loaded` | `main.go` after `config.Resolve()` | validate | Config validation, reject insecure options, environment checks | +| S2 | `startup.plugins_ready` | `main.go` after `mgr.Initialize()` | observe | Service discovery registration, cache warming, readiness signaling | +| S3 | `startup.shutdown` | `main.go` on SIGTERM path | observe | Flush buffered state, deregister from service discovery, close connections | + +**Why three startup hooks?** S1 catches invalid config before any listener accepts traffic, so a production-hardening plugin can reject `insecure_options` at that point. S2 signals readiness for external integrations. S3 ensures clean shutdown. + +### 3.3 Inbound hooks + +| ID | Name | Call site | Mode | Use Cases | +|---|---|---|---|---| +| I1 | `inbound.received` | `HandleInbound()` entry | observe | Request logging, rate-limit probes, request tagging | +| I2 | `inbound.pre_validation` | After bypass check, before `verifier.Verify()` | validate | IP allowlist, custom token format support, external authz gateway, short-circuit before JWT validation | +| I3 | `inbound.post_validation` | After `verifier.Verify()` succeeds | validate | Scope-based access control, claim enrichment from external store, custom audience checks, role mapping | +| I4 | `inbound.decision` | Before returning inbound result | validate | Final inbound gate, maintenance mode, global deny policies, cross-cutting authorization | +| I5 | `inbound.completed` | After result is determined | observe (background) | Terminal audit with outcome (allow/deny, claims, latency), analytics export | + +**Extensions available per hook:** + +| Extension | I1 | I2 | I3 | I4 | I5 | +|---|---|---|---|---|---| +| `Meta` (Tags, Scope, Properties) | yes | yes | yes | yes | yes | +| `Security.Agent` (ClientID, WorkloadID, TrustDomain) | yes | yes | yes | yes | yes | +| `Security.Subject` (SubjectID, Issuer, Audience, Scopes, Claims) | - | - | yes | yes | yes | +| `Security.Token` (raw bearer, requires `read_token`) | - | yes | yes | yes | yes | +| `Http` (Method, Path, Host, RequestHeaders — requires `read_http`) | yes | yes | yes | yes | yes | + +### 3.4 Outbound hooks + +| ID | Name | Call site | Mode | Use Cases | +|---|---|---|---|---| +| O1 | `outbound.received` | `HandleOutbound()` entry | observe | Request logging, outbound tracking | +| O2 | `outbound.post_route` | After `router.Resolve()` | validate | Route-based policy, audience override, scope narrowing, deny before exchange | +| O3 | `outbound.pre_exchange` | Before `exchanger.Exchange()` | modify | Scope attenuation, dynamic audience derivation, actor token injection, token endpoint override | +| O4 | `outbound.post_exchange` | After successful `exchanger.Exchange()` | validate | Token inspection, delegation chain validation, cache policy override | +| O5 | `outbound.decision` | Before returning outbound result | validate | Final outbound gate, global outbound policies | +| O6 | `outbound.completed` | After result is determined | observe (background) | Terminal audit with outcome (allow/replace/deny, host, latency), analytics export | + +**Extensions available per hook:** + +| Extension | O1 | O2 | O3 | O4 | O5 | O6 | +|---|---|---|---|---|---|---| +| `Meta` (Tags, Scope, Properties) | yes | yes | yes | yes | yes | yes | +| `Security.Agent` (ClientID, WorkloadID, TrustDomain) | yes | yes | yes | yes | yes | yes | +| `Security.Subject` (SubjectID, Issuer, Audience, Scopes, Claims) | * | * | * | * | * | * | +| `Security.Token` (raw bearer, requires `read_token`) | - | - | yes | yes | yes | yes | +| `Http` (Method, Path, Host, RequestHeaders — requires `read_http`) | yes | yes | yes | yes | yes | yes | +| `Delegation.Chain` (actor token delegation hops) | - | - | yes | yes | yes | yes | + +\* Available if inbound validation ran before outbound (e.g., waypoint mode). + +### 3.5 Cross-cutting hooks + +| ID | Name | Call site | Mode | Use Cases | +|---|---|---|---|---| +| A1 | `audit.request_logged` | After both inbound and outbound complete | observe (background) | Combined audit record for the full request lifecycle, compliance logging | + +Fires exactly once per request in modes where both inbound and outbound execute (waypoint). + +## 4. Hook Payloads + +Each hook in the [catalog](#3-hook-catalog) has a typed payload that AuthBridge constructs from request context before invoking CPEX. These are AuthBridge-specific types, not CPEX built-ins. CPEX operates on any `PluginPayload`; the structs below are the concrete payloads for AuthBridge's auth lifecycle hooks. + +### 4.1 Shared context type + +Every hook receives an `AuthBridgeHookCtx` providing ambient context: + +```go +type AuthBridgeHookCtx struct { + Direction string // "inbound", "outbound", or "startup" + Mode string // "envoy-sidecar", "waypoint", "proxy-sidecar" + RequestID string // unique per request (UUID) + Timestamp int64 // Unix nanos at hook invocation +} +``` + +`Direction` is included for cross-cutting plugins that subscribe to hooks in multiple families (e.g., a single audit plugin handling both I5 and O6) and need to branch on direction without inspecting the hook name. + +### 4.2 Startup payloads + +```go +// S1: startup.config_loaded +type ConfigLoadedPayload struct { + Ctx AuthBridgeHookCtx + Mode string // deployment mode + Config map[string]any // serialized config (no secrets) +} + +// S2: startup.plugins_ready +type PluginsReadyPayload struct { + Ctx AuthBridgeHookCtx + PluginCount int + PluginNames []string +} + +// S3: startup.shutdown +type ShutdownPayload struct { + Ctx AuthBridgeHookCtx + DeadlineUnixNs int64 // shutdown deadline +} +``` + +### 4.3 Inbound payloads + +```go +// I1: inbound.received +type InboundReceivedPayload struct { + Ctx AuthBridgeHookCtx + Path string + HasAuth bool // whether Authorization header is present + Bypassed bool // whether path matched bypass patterns +} + +// I2: inbound.pre_validation +type InboundPreValidationPayload struct { + Ctx AuthBridgeHookCtx + Path string + Audience string // expected audience for validation +} +// Result: PluginResult — deny skips validation and denies the request. + +// I3: inbound.post_validation +type InboundPostValidationPayload struct { + Ctx AuthBridgeHookCtx + Path string + Validated bool // true if JWT was valid +} +// Claims are carried in Extensions.Security.Subject (populated from validation results). + +// I4: inbound.decision +type InboundDecisionPayload struct { + Ctx AuthBridgeHookCtx + Action string // "allow" or "deny" (built-in decision) + Path string +} +// Result: PluginResult — deny overrides allow; allow cannot override deny (tighten-only). + +// I5: inbound.completed +type InboundCompletedPayload struct { + Ctx AuthBridgeHookCtx + Action string // final action + Path string + DenyStatus int // 0 if allowed + DenyReason string + DurationNs int64 // time spent in HandleInbound +} +``` + +### 4.4 Outbound payloads + +```go +// O1: outbound.received +type OutboundReceivedPayload struct { + Ctx AuthBridgeHookCtx + Host string + HasAuth bool +} + +// O2: outbound.post_route +type OutboundPostRoutePayload struct { + Ctx AuthBridgeHookCtx + Host string + Matched bool // whether a configured route matched + Audience string + Scopes string + Passthrough bool +} +// Result: deny aborts before exchange. + +// O3: outbound.pre_exchange (modify mode) +type OutboundPreExchangePayload struct { + Ctx AuthBridgeHookCtx + Host string + Audience string + Scopes string + TokenEndpoint string + HasActorToken bool +} +// Result: ModifiedPayload can change Audience, Scopes, TokenEndpoint. + +// O4: outbound.post_exchange +type OutboundPostExchangePayload struct { + Ctx AuthBridgeHookCtx + Host string + Audience string + ExpiresIn int // seconds + CacheHit bool +} +// The exchanged token is NOT in the payload. It is in Extensions.Security.Token (capability-gated). + +// O5: outbound.decision +type OutboundDecisionPayload struct { + Ctx AuthBridgeHookCtx + Action string // "allow", "replace_token", or "deny" + Host string +} + +// O6: outbound.completed +type OutboundCompletedPayload struct { + Ctx AuthBridgeHookCtx + Action string + Host string + DenyStatus int + DenyReason string + CacheHit bool + DurationNs int64 +} +``` + +### 4.5 Cross-cutting payloads + +```go +// A1: audit.request_logged +type RequestLoggedPayload struct { + Ctx AuthBridgeHookCtx + InboundAction string // "" if inbound was not executed + OutboundAction string // "" if outbound was not executed + Host string + Path string + TotalDurationNs int64 +} +``` + +### 4.6 Extension population + +At each hook invocation, AuthBridge populates CPEX `Extensions` from internal state. The per-hook availability tables in [§3.3](#33-inbound-hooks) and [§3.4](#34-outbound-hooks) show which extensions are present at each hook. The following table summarizes how AuthBridge concepts map to CPEX extension fields. + +| Extension field | Populated from | Notes | +|---|---|---| +| `Meta.Tags` | Deployment mode + conditional flags (e.g., `["envoy-sidecar", "bypassed"]`) | Set by AuthBridge at invocation time from runtime state, not from hook config. Plugins can use tags in policy conditions to vary behavior by deployment mode. | +| `Meta.Properties` | Route resolution: `{"audience": ..., "scopes": ..., "no_token_policy": ...}` | Available after route resolution (O2+) | +| `Security.Subject.SubjectID` | JWT `sub` claim | Available after validation (I3+) | +| `Security.Subject.Issuer` | JWT `iss` claim | Available after validation (I3+) | +| `Security.Subject.Audience` | JWT `aud` claim | Available after validation (I3+) | +| `Security.Subject.ClientID` | OAuth `client_id` | Available after validation (I3+) | +| `Security.Subject.Scopes` | OAuth scope | Available after validation (I3+) | +| `Security.Subject.Claims` | All raw JWT claims | Available after validation (I3+) | +| `Security.Agent.WorkloadID` | SPIFFE URI (e.g., `spiffe://localtest.me/ns/team1/sa/weather-tool`) | Workload identity — always available | +| `Security.Agent.ClientID` | OAuth client ID from `IdentityConfig` | Always available | +| `Security.AuthMethod` | `"jwt"`, `"spiffe"`, or `"client-secret"` | Always available | +| `Security.Token` | Raw bearer token | **Capability-gated**: requires `read_token` | +| `Http.Method` | From protocol adapter | Always available | +| `Http.Path` | From protocol adapter | Always available | +| `Http.Host` | From protocol adapter | Always available | +| `Http.RequestHeaders` | Request headers from protocol adapter | **Capability-gated**: requires `read_http` | +| `Delegation.Chain` | Actor token delegation hops | Available when actor token is present (O3+) | +| `Labels` | Accumulated security labels | Monotonic: can only grow | +| `Custom` | `{"route.matched": bool, "cache.hit": bool, ...}` | Context-dependent | + +**Extension notes:** + +- **`Security.Subject`**: carries first-class fields for OAuth/JWT identity (`SubjectID`, `Issuer`, `Audience`, `ClientID`, `Scopes`) alongside a `Claims` dict for the full raw claim set. +- **`Security.Agent`**: carries the workload identity of the AuthBridge instance itself (`ClientID`, `WorkloadID` as SPIFFE URI, `TrustDomain`). +- **`Http`**: carries HTTP request primitives (`Method`, `Path`, `Host`, `RequestHeaders`) from the protocol adapter. +- **`Delegation`**: carries the token delegation chain. Each `DelegationStep` records who delegated to whom, with what scopes, and via what strategy. AuthBridge populates this from actor tokens during outbound processing. + +### 4.7 Mapping AuthBridge concepts to CPEX types + +| AuthBridge concept | CPEX Extension mapping | +|---|---| +| `auth.IdentityConfig{ClientID, Audience}` | `Security.Agent{ClientID, WorkloadID, TrustDomain}` | +| `validation.Claims{Subject, Issuer, Audience, ClientID, Scopes, Extra}` | `Security.Subject{SubjectID, Issuer, Audience, ClientID, Scopes, Claims}` | +| `routing.ResolvedRoute{Audience, Scopes, Passthrough}` | `Meta{Properties: {"audience": ..., "scopes": ...}}` | +| `auth.InboundResult{Action, Claims, DenyStatus}` | `PluginResult{continue_processing, violation}` | +| `auth.OutboundResult{Action, Token, DenyStatus}` | `PluginResult{continue_processing, modified_payload}` | +| Actor token chain | `Delegation{Chain: []DelegationStep{Delegator, Delegate, Scopes, ...}}` | +| Request headers | `Http{Method, Path, Host, RequestHeaders}` | +| Direction (inbound/outbound) | Separate hook families: `inbound.*` vs `outbound.*` | + +## 5. Dispatch Semantics + +### 5.1 Mode-to-phase mapping + +The catalog uses shorthand labels (`observe`, `validate`, `modify`) to describe what each hook allows. These map to CPEX's `PluginMode` enum values that plugins configure in YAML: + +| Catalog label | Allowed `PluginMode` values (YAML `mode:`) | Default | +|---|---|---| +| observe | `audit`, `fire_and_forget` | Per catalog (see [§3](#3-hook-catalog)) | +| validate | `sequential`, `concurrent` | `sequential` | +| modify | `transform` | `transform` | + +AuthBridge validates at startup that each plugin subscription uses a `PluginMode` compatible with the hook's catalog label. Within a phase, plugins run by ascending `priority:`, then YAML position. Across phases, CPEX uses its canonical order: sequential → transform → audit → concurrent → fire-and-forget. + +### 5.2 Result handling per mode + +After the CPEX executor returns a `PluginResult`, the call site applies it per mode: + +| Mode | `ContinueProcessing=true` | `ContinueProcessing=false` | `ModifiedPayload` | +|---|---|---|---| +| observe | proceed | log at error, proceed | discarded | +| modify | proceed | log at error, proceed | replace payload, re-validate via `HookPayloadPolicy` | +| validate | proceed | abort per [Short-circuit behavior](#56-short-circuit-behavior) | accepted in `sequential` only; `concurrent` cannot mutate | + +A plugin returning a modified payload with forbidden field changes fails per its `on_error:` setting. + +**Why modify mode cannot block:** Transform-mode plugins exist to mutate payloads (e.g., rewriting the audience before token exchange), not to make allow/deny decisions. If a transform plugin returns `ContinueProcessing=false`, it is treated as a plugin error, and the pipeline logs it and proceeds according to the plugin's `on_error:` setting. Policy decisions belong in `validate` hooks. + +### 5.3 Failure modes and timeouts + +Each plugin has `on_error:` (from CPEX: `fail`, `ignore`, `disable`) and `timeout_ms:`. Defaults: + +| Mode | Default `on_error` | Default `timeout_ms` | +|---|---|---| +| observe | ignore | 50 | +| modify | fail | 20 | +| validate | fail | 100 | +| startup (S1) | fail | 5000 | +| background (I5, O6, A1) | ignore | 200 | + +**Why these budgets?** AuthBridge handles every request in a pod. A 100 ms validate timeout keeps the worst case under Envoy's ext_proc deadline (default 200 ms). Observation hooks at 50 ms leave headroom. Background hooks are non-blocking, so their timeout only affects resource cleanup. + +### 5.4 Tighten-only composition + +All parties (built-in check + all plugins) must agree to allow. Any single deny wins. + +For every `validate` hook, plugin results compose with the built-in check via a monoid that can only tighten: + +``` +Built-in Allow + Plugin Allow → Allow +Built-in Allow + Plugin Deny → Deny (plugin tightens) +Built-in Deny + Plugin Allow → Deny (cannot weaken) +Built-in Deny + Plugin Deny → Deny +``` + +This is enforced in the dispatcher's reduce step, not per call site. A plugin voting "allow" never overrides a built-in "deny". + +**When does a plugin see a built-in deny?** At decision hooks (I4, O5), the built-in result is already determined. The plugin receives the built-in decision in the payload (e.g., `InboundDecisionPayload.Action = "deny"`). A plugin can observe the deny for audit purposes but cannot override it to allow. At earlier hooks (I2, I3, O2, O4), the built-in has not decided yet. The plugin can deny preemptively via short-circuit, but the built-in has not yet had a chance to allow or deny. + +**Example:** Built-in validation allows a request (valid JWT, correct audience). The `scope-policy` plugin denies it because the `admin` scope is missing. Result: **denied**. The plugin tightened the decision. + +For outbound decision hooks (O5), the same principle applies to action types: + +``` +Tighten order: Allow < ReplaceToken < Deny +``` + +A plugin can escalate from Allow to Deny, but cannot de-escalate from Deny to Allow. + +### 5.5 HookPayloadPolicy + +CPEX's `HookPayloadPolicy` declares writable fields for `transform` and `sequential` phases: + +| Hook | Writable | Forbidden | +|---|---|---| +| O3 (pre_exchange) | `Audience`, `Scopes`, `TokenEndpoint` | `Host`, `Ctx` (read-only context) | +| I4 (inbound.decision) | (no payload mutation, only allow/deny) | all payload fields | +| O5 (outbound.decision) | (no payload mutation, only allow/deny) | all payload fields | + +Violations are treated per the plugin's `on_error:`. + +### 5.6 Short-circuit behavior + +| Hook | What "short-circuit" does | +|---|---| +| S1 (config_loaded) | `main.go` exits with non-zero status. No listener starts. | +| I2 (pre_validation) | JWT validation skipped. `HandleInbound()` returns deny with the plugin's violation. | +| I3 (post_validation) | `HandleInbound()` returns deny with the plugin's violation. Claims already validated. | +| I4 (inbound.decision) | Overrides the built-in result. Tighten-only: can deny but not allow a denied request. | +| O2 (post_route) | Token exchange skipped. `HandleOutbound()` returns deny. | +| O3 (pre_exchange) | Cannot short-circuit (modify mode). Exchange proceeds with modified or original params. | +| O4 (post_exchange) | `HandleOutbound()` returns deny. Token is discarded. | +| O5 (outbound.decision) | Overrides the built-in result. Tighten-only. | + +## 6. Configuration + +### 6.1 YAML surface + +AuthBridge's existing YAML config gains a `plugins:` section. All existing sections are unchanged. + +```yaml +mode: envoy-sidecar +inbound: + issuer: ${ISSUER} +outbound: + keycloak_url: ${KEYCLOAK_URL} + keycloak_realm: ${KEYCLOAK_REALM} + +plugins: + - name: scope-policy + kind: native:///opt/authbridge/plugins/libscopepolicy.so + subscribe: + - hook: inbound.post_validation # I3 + mode: sequential + priority: 10 + on_error: fail + timeout_ms: 50 + capabilities: + - read_security + config: + required_scopes: + /api/v1/admin: ["admin"] + + - name: audit-log + kind: wasm:///opt/authbridge/plugins/audit.wasm + subscribe: + - hook: inbound.completed # I5 + mode: fire_and_forget + - hook: outbound.completed # O6 + mode: fire_and_forget + capabilities: + - read_security + - read_http + config: + sink: https://audit.internal/v1/events +``` + +### 6.2 Field semantics + +| Field | Required | Meaning | +|---|---|---| +| `name` | yes | Unique plugin identifier; used in logs and metrics. | +| `kind` | yes | Scheme-prefixed location: `native://`, `wasm://`, `builtin:`. Selects the host. | +| `version` | no | Advisory string, logged at startup. | +| `subscribe[].hook` | yes | Hook name from the [catalog](#3-hook-catalog). | +| `subscribe[].mode` | no | CPEX `PluginMode` value (`sequential`, `transform`, `audit`, `concurrent`, `fire_and_forget`); default from the catalog; validated against the hook's allowed modes. | +| `subscribe[].priority` | no | Ordering within a phase; default 100. | +| `subscribe[].on_error` | no | Default per mode (see [Failure modes](#53-failure-modes-and-timeouts)). | +| `subscribe[].timeout_ms` | no | Default per mode (see [Failure modes](#53-failure-modes-and-timeouts)). | +| `capabilities` | no | Declared capabilities for extension gating. | +| `config` | no | Opaque plugin-specific YAML; handed to `Plugin::initialize` as JSON. | + +### 6.3 Startup validation + +Startup fails when: + +- Two plugins share a `name`. +- A `subscribe[].hook` is not a registered AuthBridge hook. +- A `subscribe[].mode` is not allowed for the hook's mode (see [§5.1](#51-mode-to-phase-mapping)). +- A plugin's `Plugin::initialize` returns an error with `on_error: fail`. +- Any S1 plugin rejects the config. +- A required plugin is missing from the `plugins:` list. + +### 6.4 Backward compatibility + +When no `plugins:` section is present in config: + +- `PluginManager` is nil. +- `HandleInbound()` / `HandleOutbound()` execute as today. +- Zero overhead: a single `if dispatcher == nil` check per request. +- Existing deployments work unchanged without any configuration modification. + +### 6.5 Environment interpolation + +Existing `${ENV_VAR}` resolution in `config.Load()` applies to `plugins[].config`. Secrets should flow through env vars, not YAML literals. + +## 7. Integration Architecture + +### 7.1 CPEX plugin runtime + +AuthBridge embeds CPEX via its Go SDK (`cpex-go`). The plugin executor dispatches to all registered plugins natively. AuthBridge provides lifecycle call sites, payload construction, extension population, and the tighten-only composition logic. + +``` +AuthBridge (Go host) +│ +├── Go Host Layer (cpex-go) +│ ├── PluginManager wrapper (lifecycle, invoke) +│ ├── Config loading (CPEX config adapter) +│ └── Result handling + tighten-only enforcement +│ +├── C FFI boundary (single crossing per invoke) +│ +└── CPEX Core (cpex-core) + ├── Plugin instances (native) + ├── Plugin executor + ├── Capability gating (per-plugin extension filtering) + ├── ContextTable (per-plugin cross-hook state) + └── Background tasks (fire-and-forget async work) +``` + +### 7.2 Package layout + +A single new package joins the AuthBridge Go module structure. + +``` +authbridge/ + authlib/ + hooks/ NEW: authbridge hooks package + hooks.go Hook type IDs, registration, public types + payloads.go Payload and result Go structs per hook + dispatcher.go HookDispatcher wrapping cpex.PluginManager + extensions.go AuthBridge → CPEX Extensions population + tighten.go Tighten-only composition logic + config.go YAML → CPEX config adapter + metrics.go authbridge_hook_* metric emitters + auth/ MODIFIED: call sites added to HandleInbound/HandleOutbound + config/ MODIFIED: plugins section added to Config + cmd/authbridge/ + main.go MODIFIED: PluginManager lifecycle +``` + +Go module dependencies: + +```go +require ( + github.com/contextforge/cpex-go v0.2.0 +) +``` + +The `cpex-go` package includes pre-built Rust shared libraries for linux/amd64, linux/arm64, and darwin/arm64. Building with `CGO_ENABLED=0` produces a binary that rejects all plugins at startup. + +### 7.3 Lifecycle integration + +The `PluginManager` is created in `main.go`, after config resolution and before listener startup. + +``` +cmd/authbridge/main.go + flag.Parse() + config.Load(configPath) + config.Resolve(ctx, cfg) → auth.Config + ── if cfg has plugins section ────────────────── + │ hooks.NewDispatcher(cfg.Plugins) NEW + │ cpex.NewPluginManager(yaml) + │ mgr.Initialize() + │ dispatch S1 config_loaded (fail-closed) + │ dispatch S2 plugins_ready (observe) + ───────────────────────────────────────────────── + auth.New(resolved, dispatcher) MODIFIED + startListeners(...) + go resolveCredentials(...) + <-sigCh + dispatch S3 shutdown (observe) + mgr.Shutdown() +``` + +The `auth.Auth` struct receives an optional `*hooks.Dispatcher`. A nil dispatcher (no `plugins:` section) collapses every call site to a single `dispatcher == nil` branch, leaving the existing code path unchanged. + +### 7.4 Call-site contract + +Every call site in the [hook catalog](#3-hook-catalog) must: + +1. **Fast-path check**: `if d.HasHooksFor(hookID)`, backed by an `AtomicBool` per hook ID. +2. **Build payload**: construct the typed payload from local context. +3. **Populate extensions**: build `cpex.Extensions` from AuthBridge's internal state. +4. **Invoke**: `result, bg, err := d.Invoke(hookID, payload, extensions, contextTable)`. +5. **Apply result**: per the hook's mode (see [Result handling](#52-result-handling-per-mode)). +6. **Emit metric**: per-hook-pair counter and histogram. + +Call sites use a helper that keeps boilerplate out of the hot path: + +```go +if d != nil && d.HasHooksFor(hooks.InboundPostValidation) { + ext := d.BuildExtensions(ctx, direction, claims, route, headers) + result, bg, _ := d.Invoke(hooks.InboundPostValidation, payload, ext, ctxTable) + bg.Close() + if result.IsDenied() { + return denyFromViolation(result.Violation) + } +} +``` + +### 7.5 What CPEX provides vs. what AuthBridge provides + +| CPEX provides | AuthBridge provides | +|---|---| +| `PluginManager` — lifecycle management, plugin loading, hook invocation | Hook type definitions (IDs, payload/result types) | +| Plugin executor — runs plugins in 5-phase order (`PluginMode`) with priority scheduling | Call sites in `HandleInbound()` / `HandleOutbound()` | +| Capability gating — filters Extensions per-plugin based on declared capabilities | Extension population from internal state (JWT claims → `Security.Subject`, etc.) | +| `PluginResult` — typed result with `continue_processing`, `modified_payload`, `violation` | Tighten-only enforcement per mode | +| `ContextTable` — per-plugin state persisting across hooks within a request | Adapter layer (ext_proc/ext_authz/proxy → Extensions) | +| `BackgroundTasks` — fire-and-forget async work outside request latency | Configuration bridge (AuthBridge YAML → CPEX YAML) | +| `OnError` modes — fail/ignore/disable per plugin | Built-in plugins (jwt-validation, token-exchange) | +| MessagePack serialization across FFI | Metrics integration with `slog` structured logging | + +## 8. Built-in Plugin Migration + +### 8.1 How `jwt-validation` wraps `authlib/validation/` + +The built-in `jwt-validation` plugin wraps `validation.LazyJWKSVerifier`: + +``` +Before (current): + HandleInbound() { + if bypass → allow + token := extractBearer(authHeader) + claims, err := verifier.Verify(ctx, token, audience) + if err → deny + return InboundResult{Allow, claims} + } + +After (with hooks): + HandleInbound() { + ── I1 inbound.received (observe) ── + if bypass → allow + ── I2 inbound.pre_validation (validate: can short-circuit) ── + token := extractBearer(authHeader) + claims, err := verifier.Verify(ctx, token, audience) + ── I3 inbound.post_validation (validate: claims in Extensions) ── + ── I4 inbound.decision (validate: final gate) ── + ── I5 inbound.completed (background) ── + return result + } +``` + +In Phase 1, the built-in plugin is informational only and the existing Go code still performs validation. In Phase 4, the jwt-validation built-in handles validation entirely, and the Go code becomes the orchestrator that invokes hooks and applies results. + +### 8.2 How `token-exchange` wraps `authlib/exchange/` + `authlib/cache/` + +``` +Before (current): + HandleOutbound() { + route := router.Resolve(host) + if passthrough → allow + token := extractBearer(authHeader) + if no token → handleNoToken() + if cached → ActionReplaceToken + resp := exchanger.Exchange(ctx, req) + cache.Set(...) + return OutboundResult{ReplaceToken, resp.AccessToken} + } + +After (with hooks): + HandleOutbound() { + ── O1 outbound.received (observe) ── + route := router.Resolve(host) + ── O2 outbound.post_route (validate) ── + if passthrough → allow + token := extractBearer(authHeader) + ── O3 outbound.pre_exchange (modify: can change audience/scopes) ── + if cached → ReplaceToken + resp := exchanger.Exchange(ctx, req) + cache.Set(...) + ── O4 outbound.post_exchange (validate) ── + ── O5 outbound.decision (validate: final gate) ── + ── O6 outbound.completed (background) ── + return result + } +``` + +### 8.3 Built-in plugin capabilities + +| Plugin | Subscribes to | Capabilities | +|---|---|---| +| `jwt-validation` | `inbound.pre_validation` (I2) | `read_token`, `write_security`, `read_http` | +| `token-exchange` | `outbound.pre_exchange` (O3) | `read_token`, `read_security`, `read_delegation`, `append_delegation`, `write_http` | + +Both are registered via `kind: builtin:` and declared as required in `plugin_settings.required_plugins`. They use the same Extensions as third-party plugins. + +### 8.4 What stays outside the pipeline + +These remain in the Go orchestration layer, not in plugins: + +- **Bypass matching** happens before any hooks fire. I1 includes the bypass result, but bypass itself is pre-hook. +- **Route resolution**: `router.Resolve()` runs before O2. Route info is available to O2+ via Extensions. +- **Identity loading**: `UpdateIdentity()` is a background process. Plugins receive the current identity via `Agent.agent_id`. +- **Bearer extraction**: `extractBearer()` is a trivial helper. Token is available via `Security.Token`. + +## 9. Adapter Layer + +### 9.1 How each deployment mode constructs Extensions + +All four protocol adapters call `HandleInbound()` / `HandleOutbound()` through the same narrow waist. The adapter extracts headers before calling, and the auth layer constructs Extensions uniformly. + +| Mode | Inbound source | Outbound source | Body access | +|---|---|---|---| +| envoy-sidecar (ext_proc) | `corev3.HeaderMap` from `ProcessingRequest_RequestHeaders` | `corev3.HeaderMap` | Requires Envoy `processing_mode.request_body: BUFFERED` (not default) | +| waypoint (ext_authz) | `httpReq.GetHeaders()` from `CheckRequest` | same headers | No body access (ext_authz protocol) | +| proxy-sidecar (reverse proxy) | `http.Request.Header` | `http.Request.Header` | Full body access via `http.Request.Body` | +| proxy-sidecar (forward proxy) | N/A | `http.Request.Header` | Full body access | + +### 9.2 Mode-specific constraints + +- **ext_authz mode (waypoint):** No body access. Plugins that require body parsing must declare this at startup; the dispatcher rejects the subscription when running in waypoint mode. +- **ext_proc mode (envoy-sidecar):** Body access requires Envoy configuration change (`processing_mode.request_body: BUFFERED`). A future operator integration could auto-configure this when body-accessing plugins are detected. +- **proxy-sidecar:** Full body access. The HTTP request is available in its entirety. + +### 9.3 Body access opt-in + +Body access is opt-in per plugin, declared in the plugin config: + +```yaml +plugins: + - name: mcp-parser + kind: native:///opt/authbridge/plugins/libmcpparser.so + subscribe: + - hook: inbound.received + config: + requires_body: true # startup validation checks mode compatibility +``` + +At startup, if `requires_body: true` and the mode is `waypoint`, startup fails with a clear error: "plugin mcp-parser requires body access but waypoint mode does not support it." + +This mechanism controls body *access*, not body *parsing*. Protocol-aware parsing (MCP JSON-RPC, A2A) is a separate concern addressed in [§13: Layering Model](#13-layering-model-and-protocol-semantic-hooks). A plugin with body access could parse the body itself, but the AuthBridge hook system does not provide structured protocol payloads. That would require a protocol-native layer such as Praxis. + +## 10. Security Invariants and Hook Mapping + +### 10.1 Built-in boundary catalogue + +AuthBridge enforces 8 load-bearing invariants. The hook system lets plugins strengthen any without silently weakening any. + +| # | Boundary | Enforced at | Current code | +|---|---|---|---| +| 1 | JWT audience mandatory | `verifier.Verify()` requires non-empty audience | `auth/auth.go:132` | +| 2 | Bypass paths normalized | `bypass.Matcher.Match()` applies `path.Clean` | `bypass/matcher.go:36` | +| 3 | Token cache keyed by SHA-256 | `cache.Get()`/`Set()` hash subject+audience | `cache/cache.go:97` | +| 4 | Failed exchange → deny outbound | `exchanger.Exchange()` error → 503 | `auth/auth.go:247` | +| 5 | No verifier → deny inbound | `verifier == nil` → deny | `auth/auth.go:126` | +| 6 | Generic error messages | `DenyReason` is generic, not error detail | `auth/auth.go:149` | +| 7 | Envoy UID excluded from iptables | `init-iptables.sh` UID 1337 exclusion | `authproxy/init-iptables.sh` | +| 8 | Response body size limit | `io.LimitReader(resp.Body, 1<<20)` | `exchange/client.go:148` | + +### 10.2 Hook mapping + +| # | Boundary | Hook(s) | Direction | +|---|---|---|---| +| 1 | JWT audience mandatory | I2, I3 | observe (I3: can add additional audience checks), tighten (deny if audience mismatch) | +| 2 | Bypass paths normalized | I1 | observe (bypass result visible, path not modifiable) | +| 3 | Token cache keyed by SHA-256 | O4 | observe (cache hit/miss visible) | +| 4 | Failed exchange → deny | O4, O5 | tighten (O5 can deny but not allow a failed exchange) | +| 5 | No verifier → deny | I2 | tighten (I2 can short-circuit with deny but not with allow) | +| 6 | Generic error messages | I4, O5 | observe (violation messages are plugin-controlled but never leak internal errors) | +| 7 | Envoy UID exclusion | (outside hook scope) | N/A (iptables is pre-process) | +| 8 | Response body limit | (outside hook scope) | N/A (exchange client is internal) | + +Every "tighten" row maps to the tighten-only monoid (see [§5.4](#54-tighten-only-composition)). No code path lets a plugin widen any boundary. + +## 11. Observability + +### 11.1 Metrics + +Three metric series per plugin-hook pair, emitted via the existing `slog` structured logging: + +``` +authbridge_hook_invocations_total{plugin, hook, outcome} continue | reject | error | timeout +authbridge_hook_duration_seconds{plugin, hook} histogram +authbridge_hook_last_error_timestamp_seconds{plugin, hook} +``` + +Aggregate counters: + +``` +authbridge_hook_fast_path_skips_total nil / HasHooksFor=false fast path +authbridge_plugins_loaded_total{host} +authbridge_plugins_disabled_total{reason} +``` + +### 11.2 Structured logging + +Each hook dispatch emits a structured log line: + +``` +level=DEBUG msg="hook invoked" hook=inbound.post_validation plugins=2 duration_us=45 +level=INFO msg="hook denied" hook=inbound.decision plugin=scope-policy reason="missing admin scope" +``` + +### 11.3 Tracing + +The request span gains plugin attributes: + +- `authbridge.plugins.invoked`: count of hooks that fired for this request. +- `authbridge.plugins.rejected_by`: name of the plugin that denied (if any). +- `authbridge.plugins.duration_us`: total hook execution time. + +## 12. Staged Rollout + +Each phase is independently shippable. Acceptance criteria are defined per phase. + +**Phase 0: Package skeleton.** +`authlib/hooks/` package, CPEX Go dependency pinned, CI build verification. No runtime changes. +*Acceptance:* `go build` succeeds with the new dependency; existing tests pass. + +**Phase 1: Startup hooks + dispatcher skeleton.** +`HookDispatcher`, `PluginManager` lifecycle in `main.go`. S1, S2, S3 hooks wired. Built-in plugin stubs (jwt-validation, token-exchange) registered but inactive. +*Acceptance:* S1 plugin can reject startup; S3 fires on SIGTERM; zero overhead without `plugins:` section. + +**Phase 2: Inbound observation hooks (fail-open).** +I1 (received) and I5 (completed) wired. Observe-only, `on_error: ignore`. Validates dispatcher fast path, metrics, and logging at production rates. +*Acceptance:* Audit-log plugin receives inbound events; <1% tail-latency impact at production rates. + +**Phase 3: Outbound observation hooks.** +O1 (received) and O6 (completed) wired. Same pattern as Phase 2. +*Acceptance:* Outbound audit events visible end-to-end. + +**Phase 4: Inbound validate hooks + tighten-only enforcement.** +I2 (pre_validation), I3 (post_validation), I4 (decision) wired. Tighten-only monoid enforced. jwt-validation built-in activated as reference. +*Acceptance:* Scope-policy plugin denies within budget; integration test verifies a plugin can deny but not allow a denied request. + +**Phase 5: Outbound validate + modify hooks.** +O2 (post_route), O3 (pre_exchange), O4 (post_exchange), O5 (decision) wired. `HookPayloadPolicy` enforced for O3. token-exchange built-in activated. +*Acceptance:* O3 plugin modifies audience; exchange uses modified audience; tighten-only verified for O5. + +**Phase 6: Protocol-semantic plugins (optional).** +MCP parser plugin pattern. Body access opt-in. Reserved hook names become live. +*Acceptance:* MCP parser reads body in proxy-sidecar mode, sets Custom extensions, downstream policy plugin uses them. + +**Phase 7: WASM / sidecar loading (optional).** +WASM host enabled. Sidecar host for async non-policy hooks. +*Acceptance:* WASM audit plugin runs within fuel budget; sidecar plugin receives fire_and_forget events. + +## 13. Layering Model and Protocol-Semantic Hooks + +### 13.1 Two hook categories + +**Auth lifecycle hooks (this spec).** Startup, inbound request processing, outbound token exchange, and cross-cutting audit. Operate on HTTP primitives: authorization headers, host, path, JWT claims, route parameters, exchanged tokens. Header-level identity and authorization fit naturally here. + +**Protocol-semantic hooks (out of scope for AuthBridge).** MCP `tools/call`, A2A tasks, LLM inputs and outputs require parsing structured request bodies into protocol-typed messages. This is the responsibility of a protocol-aware proxy or gateway (e.g., Praxis), not the auth composition layer. AuthBridge operates on HTTP primitives (headers, host, path, JWT claims) and does not buffer or parse request bodies. Protocol-semantic hook support belongs in Praxis, which already defines content-level hooks for tool calls, identity resolution, and session management. This spec names no such hooks. + +### 13.2 Why protocol parsing doesn't belong in the auth layer + +AuthBridge's `HandleInbound()` and `HandleOutbound()` operate on authorization headers. They receive the `authHeader`, `path`, and `host`, but never the request body. Adding body buffering to the auth layer would: + +1. **Violate the narrow waist.** The ext_proc adapter only requests headers; enabling body access requires Envoy configuration changes. The ext_authz adapter has no body access. Only the HTTP proxy listeners can access bodies. +2. **Break mode independence.** Body access would work in proxy-sidecar mode but not in envoy-sidecar or waypoint modes without Envoy reconfiguration. +3. **Add latency.** Body buffering on the auth hot path penalizes every request, not just those needing protocol parsing. + +### 13.3 How protocol-semantic hooks ship later + +Protocol-semantic hooks are unblocked when AuthBridge (or a companion component) grows protocol-aware middleware. Plausible shapes: + +- An MCP parser plugin subscribes to an inbound observation hook, reads body content (opt-in, proxy-sidecar only), parses JSON-RPC, and sets `Custom["mcp.method"]` for downstream policy plugins. +- A dedicated `McpProxy` mode where AuthBridge buffers and parses MCP requests, dispatching `mcp.tool_pre_invoke` hooks. + +Until then, protocol-semantic hook names are reserved (namespace `mcp.*`, `a2a.*`) but unregistered. Plugins must not subscribe to reserved names; the loader rejects unregistered hooks. + +## 14. Open Questions + +1. **Body buffering budget.** Protocol-semantic plugins need body access, but body buffering on the auth hot path adds latency. Should body access be a per-plugin opt-in with a mode-validated startup check (current proposal), or should a separate body-processing pipeline run after the auth pipeline? + +2. **Async fire-and-forget for audit.** Fire-and-forget hooks (I5, O6, A1) use Rust's tokio runtime for background execution. Should AuthBridge expose a Go-side async mechanism as well, or is the Rust background task system sufficient? + +3. **ext_proc body auto-configuration via operator.** When a body-accessing plugin is detected, should the operator automatically reconfigure Envoy's `processing_mode`? This crosses the boundary between AuthBridge and kagenti-operator. + +4. **Cross-request plugin state (sessions).** CPEX's `ContextTable` is per-request. A rate-limiting plugin needs cross-request state. Should this be supported via a persistence layer on top of the context table, or should stateful plugins use external storage (Redis, etc.)? + +--- + +## Appendix A: Complete Go Payload Type Definitions + +Full payload types with MessagePack serialization tags. + +```go +package hooks + +type AuthBridgeHookCtx struct { + Direction string `msgpack:"direction"` + Mode string `msgpack:"mode"` + RequestID string `msgpack:"request_id"` + Timestamp int64 `msgpack:"timestamp_ns"` +} + +// --- Startup --- + +type ConfigLoadedPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Mode string `msgpack:"mode"` + Config map[string]any `msgpack:"config"` +} + +type PluginsReadyPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + PluginCount int `msgpack:"plugin_count"` + PluginNames []string `msgpack:"plugin_names"` +} + +type ShutdownPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + DeadlineUnixNs int64 `msgpack:"deadline_unix_ns"` +} + +// --- Inbound --- + +type InboundReceivedPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Path string `msgpack:"path"` + HasAuth bool `msgpack:"has_auth"` + Bypassed bool `msgpack:"bypassed"` +} + +type InboundPreValidationPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Path string `msgpack:"path"` + Audience string `msgpack:"audience"` +} + +type InboundPostValidationPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Path string `msgpack:"path"` + Validated bool `msgpack:"validated"` +} + +type InboundDecisionPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Action string `msgpack:"action"` + Path string `msgpack:"path"` +} + +type InboundCompletedPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Action string `msgpack:"action"` + Path string `msgpack:"path"` + DenyStatus int `msgpack:"deny_status"` + DenyReason string `msgpack:"deny_reason"` + DurationNs int64 `msgpack:"duration_ns"` +} + +// --- Outbound --- + +type OutboundReceivedPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Host string `msgpack:"host"` + HasAuth bool `msgpack:"has_auth"` +} + +type OutboundPostRoutePayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Host string `msgpack:"host"` + Matched bool `msgpack:"matched"` + Audience string `msgpack:"audience"` + Scopes string `msgpack:"scopes"` + Passthrough bool `msgpack:"passthrough"` +} + +type OutboundPreExchangePayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Host string `msgpack:"host"` + Audience string `msgpack:"audience"` + Scopes string `msgpack:"scopes"` + TokenEndpoint string `msgpack:"token_endpoint"` + HasActorToken bool `msgpack:"has_actor_token"` +} + +type OutboundPostExchangePayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Host string `msgpack:"host"` + Audience string `msgpack:"audience"` + ExpiresIn int `msgpack:"expires_in"` + CacheHit bool `msgpack:"cache_hit"` +} + +type OutboundDecisionPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Action string `msgpack:"action"` + Host string `msgpack:"host"` +} + +type OutboundCompletedPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + Action string `msgpack:"action"` + Host string `msgpack:"host"` + DenyStatus int `msgpack:"deny_status"` + DenyReason string `msgpack:"deny_reason"` + CacheHit bool `msgpack:"cache_hit"` + DurationNs int64 `msgpack:"duration_ns"` +} + +// --- Cross-cutting --- + +type RequestLoggedPayload struct { + Ctx AuthBridgeHookCtx `msgpack:"ctx"` + InboundAction string `msgpack:"inbound_action"` + OutboundAction string `msgpack:"outbound_action"` + Host string `msgpack:"host"` + Path string `msgpack:"path"` + TotalDurationNs int64 `msgpack:"total_duration_ns"` +} +``` + +## Appendix B: Full YAML Configuration Example + +```yaml +mode: envoy-sidecar + +inbound: + issuer: ${ISSUER} +outbound: + keycloak_url: ${KEYCLOAK_URL} + keycloak_realm: ${KEYCLOAK_REALM} +identity: + type: spiffe + jwt_svid_path: /opt/jwt_svid.token + client_id_file: /shared/client-id.txt + client_secret_file: /shared/client-secret.txt +listener: + ext_proc_addr: ":9090" +bypass: + inbound_paths: + - "/.well-known/*" + - "/healthz" + - "/readyz" + - "/livez" + +plugin_settings: + required_plugins: + - jwt-validation + - token-exchange + +plugins: + # Built-in: JWT validation (required) + - name: jwt-validation + kind: builtin:jwt-validation + subscribe: + - hook: inbound.pre_validation + mode: sequential + priority: 1 + on_error: fail + capabilities: + - read_token + - write_security + - read_http + + # Built-in: Token exchange (required) + - name: token-exchange + kind: builtin:token-exchange + subscribe: + - hook: outbound.pre_exchange + mode: transform + priority: 1 + on_error: fail + capabilities: + - read_token + - read_security + - read_delegation + - append_delegation + - write_http + + # Custom: Scope-based access policy + - name: scope-policy + kind: native:///opt/authbridge/plugins/libscopepolicy.so + version: "1.0.0" + subscribe: + - hook: inbound.post_validation + mode: sequential + priority: 10 + on_error: fail + timeout_ms: 50 + capabilities: + - read_security + config: + required_scopes: + /api/v1/admin: ["admin"] + /api/v1/data: ["read:data"] + + # Custom: Audit logger (WASM sandbox) + - name: audit-log + kind: wasm:///opt/authbridge/plugins/audit.wasm + version: "0.3.0" + subscribe: + - hook: inbound.completed + mode: fire_and_forget + priority: 100 + on_error: ignore + timeout_ms: 200 + - hook: outbound.completed + mode: fire_and_forget + priority: 100 + on_error: ignore + capabilities: + - read_security + - read_http + config: + sink: https://audit.internal/v1/events + batch_size: 100 + flush_interval_ms: 5000 + + # Custom: Production hardening + - name: prod-hardening + kind: builtin:config-guard + subscribe: + - hook: startup.config_loaded + on_error: fail + config: + require_spiffe: true + deny_modes: ["proxy-sidecar"] + + # Custom: Rate limiter (outbound) + - name: outbound-rate-limiter + kind: native:///opt/authbridge/plugins/libratelimit.so + subscribe: + - hook: outbound.post_route + mode: sequential + priority: 5 + on_error: fail + timeout_ms: 10 + capabilities: + - read_security + config: + default_rps: 100 + per_audience: + github-tool: 30 +``` + +## Appendix C: Reserved Protocol-Semantic Hook Names + +These names are reserved but unregistered. They become live hook points when protocol-native services land (see [§13.3](#133-how-protocol-semantic-hooks-ship-later)). + +``` +mcp.tool_pre_invoke +mcp.tool_post_invoke +mcp.resource_pre_fetch +mcp.resource_post_fetch + +a2a.task_created +a2a.task_updated +a2a.task_completed +``` + +Plugins must not subscribe to these names in v1; the loader rejects any subscription to an unregistered hook. + +## Appendix D: Plugin Examples + +These examples use the CPEX Rust plugin SDK (`cpex-sdk`). Each includes a Rust plugin skeleton and the YAML config to wire it into AuthBridge. + +### D.1 Scope-Based Access Policy (validate at I3) + +A plugin that denies requests missing required scopes for specific paths. + +**Rust plugin:** + +```rust +use cpex_sdk::prelude::*; +use std::collections::HashMap; + +pub struct ScopePolicy { + required: HashMap>, +} + +impl Plugin for ScopePolicy { + fn initialize(config: serde_json::Value) -> Result { + let required: HashMap> = + serde_json::from_value(config["required_scopes"].clone())?; + Ok(Self { required }) + } +} + +impl HookHandler for ScopePolicy { + fn handle( + &self, + payload: &InboundPostValidationPayload, + ext: &FilteredExtensions, + ) -> PipelineResult { + let Some(subject) = ext.security().and_then(|s| s.subject()) else { + return PipelineResult::violation("no authenticated subject"); + }; + let scope_str = subject.claims().get("scope").unwrap_or(&String::new()).clone(); + let scopes: Vec<&str> = scope_str.split_whitespace().collect(); + + if let Some(required) = self.required.get(&payload.path) { + for r in required { + if !scopes.contains(&r.as_str()) { + return PipelineResult::violation( + format!("missing required scope: {r}"), + ); + } + } + } + PipelineResult::continue_processing() + } +} +``` + +**YAML config:** + +```yaml +- name: scope-policy + kind: native:///opt/authbridge/plugins/libscopepolicy.so + subscribe: + - hook: inbound.post_validation + mode: sequential + priority: 10 + on_error: fail + timeout_ms: 50 + capabilities: + - read_security + config: + required_scopes: + /api/v1/admin: ["admin"] + /api/v1/data: ["read:data"] +``` + +### D.2 Audit Logger (observe at I5/O6) + +A fire-and-forget plugin that collects terminal events and sends them to an external audit sink. + +**Rust plugin:** + +```rust +use cpex_sdk::prelude::*; + +pub struct AuditLogger { + sink_url: String, +} + +impl Plugin for AuditLogger { + fn initialize(config: serde_json::Value) -> Result { + Ok(Self { + sink_url: config["sink"].as_str().unwrap_or_default().to_string(), + }) + } +} + +impl HookHandler for AuditLogger { + fn handle( + &self, + payload: &InboundCompletedPayload, + ext: &FilteredExtensions, + ) -> PipelineResult { + let subject_id = ext.security() + .and_then(|s| s.subject()) + .map(|s| s.id().to_string()) + .unwrap_or_default(); + + let event = serde_json::json!({ + "direction": "inbound", + "action": payload.action, + "path": payload.path, + "subject": subject_id, + "duration_ns": payload.duration_ns, + }); + + // Fire-and-forget: queue for async delivery + cpex_sdk::background::spawn(async move { + let _ = reqwest::Client::new() + .post(&self.sink_url) + .json(&event) + .send() + .await; + }); + + PipelineResult::continue_processing() + } +} +``` + +**YAML config:** + +```yaml +- name: audit-log + kind: wasm:///opt/authbridge/plugins/audit.wasm + subscribe: + - hook: inbound.completed + mode: fire_and_forget + - hook: outbound.completed + mode: fire_and_forget + capabilities: + - read_security + - read_http + config: + sink: https://audit.internal/v1/events +``` + +### D.3 Dynamic Audience Derivation (modify at O3) + +A plugin that rewrites the token exchange audience based on custom routing logic. + +**Rust plugin:** + +```rust +use cpex_sdk::prelude::*; +use std::collections::HashMap; + +pub struct AudienceDerivation { + overrides: HashMap, +} + +impl Plugin for AudienceDerivation { + fn initialize(config: serde_json::Value) -> Result { + let overrides: HashMap = + serde_json::from_value(config["audience_overrides"].clone())?; + Ok(Self { overrides }) + } +} + +impl HookHandler for AudienceDerivation { + fn handle( + &self, + payload: &OutboundPreExchangePayload, + _ext: &FilteredExtensions, + ) -> PipelineResult { + if let Some(new_audience) = self.overrides.get(&payload.host) { + let mut modified = payload.clone(); + modified.audience = new_audience.clone(); + return PipelineResult::modified(modified); + } + PipelineResult::continue_processing() + } +} +``` + +**YAML config:** + +```yaml +- name: audience-derivation + kind: native:///opt/authbridge/plugins/libaudiencederiv.so + subscribe: + - hook: outbound.pre_exchange + mode: transform + priority: 5 + on_error: fail + timeout_ms: 10 + config: + audience_overrides: + api.internal.svc: "https://api.internal/v2" + legacy.svc: "https://legacy-api.corp.com" +``` + +## Appendix E: Plugin ABI and Loading + +### E.1 Supported hosts + +| Host | CPEX feature | AuthBridge default | Notes | +|---|---|---|---| +| Native (Rust `.so`/`.dylib`) | `cpex-hosts::native` | on | Primary path. Zero marshalling within Rust. | +| WASM (`.wasm`) | `cpex-hosts::wasm` | on | wasmtime sandbox. 20-50 us marshalling per hook. | +| Sidecar (UDS gRPC) | future | off | Reserved for async non-policy hooks. | + +### E.2 Trust boundary + +All native plugins run with AuthBridge process privileges. WASM plugins are sandboxed. Consequences: + +- Native plugins can crash AuthBridge, leak memory, or call syscalls. Plugin provenance is the operator's responsibility. +- WASM plugins cannot escape the sandbox; they can exhaust fuel and time out. +- No plugin can widen a boundary from [Security Invariants](#10-security-invariants-and-hook-mapping). Attempts fail at dispatch via the tighten-only monoid. + +### E.3 Load order + +1. AuthBridge creates the CPEX `PluginManager` and registers all hook type definitions. +2. For each plugin in YAML order, the manager selects the host from `kind:`, loads the binary, calls `Plugin::initialize`. +3. For each `subscribe:` entry, the framework looks up the hook and registers the handler with mode, priority, and `on_error`. +4. S1 `startup.config_loaded` fires across all plugins. Any failure aborts startup. +5. S2 `startup.plugins_ready` fires. Observe-only: failures are logged but don't abort. + +### E.4 Required plugins enforcement + +```yaml +plugin_settings: + required_plugins: + - jwt-validation + - token-exchange +``` + +Startup validation rejects any config where a required plugin is missing from the `plugins:` list. The host sets the required list, and plugin authors cannot override it. From 89288d94394a45bb08b2856ad94ea90e8d8fe0f9 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 00:40:11 -0400 Subject: [PATCH 02/18] fix(cpex): harden session-taint isolation and inbound host-bypass Addresses two findings from the security review of the CPEX plugin (commit a150c84). Finding 1 (host-bypass): the cpex plugin skipped the entire policy chain when the HTTP Host matched bypass_hosts. On the inbound reverse-proxy phase the Host is attacker-controlled and identity is resolved inside the very chain being bypassed, so a spoofed Host defeated all enforcement. Honor bypass_hosts on the outbound phase only; ignore it inbound (path bypass still applies) and warn at Configure time since direction is unknown there. Finding 2 (spoofable session taint): session-scoped taint labels were keyed off the client-supplied X-Session-Id / A2A contextId, letting one principal read or pollute another principal's session state. Fixed in the CPEX framework (session_resolver subject-binds Tier 0/1 as sha256(subject_id : value)); bump the FFI to v0.2.0-ffi.test.8 and the cpex Go bindings to match. Add demos/hr-cpex scenario 09 proving cross-principal isolation (eve taints a session; bob reusing her session id is not blocked) and extend the scenarios glob to include it. Signed-off-by: Frederico Araujo --- authbridge/authlib/go.mod | 2 +- authbridge/authlib/go.sum | 4 +- .../authlib/plugins/cpex/manager_test.go | 4 +- authbridge/authlib/plugins/cpex/plugin.go | 19 +++++- .../authlib/plugins/cpex/plugin_test.go | 30 ++++++++- .../cmd/authbridge-cpex/CPEX_FFI_VERSION | 2 +- authbridge/cmd/authbridge-cpex/go.mod | 2 +- authbridge/cmd/authbridge-cpex/go.sum | 4 +- authbridge/demos/hr-cpex/Makefile | 4 +- .../09-cross-principal-taint-isolation.sh | 61 +++++++++++++++++++ 10 files changed, 117 insertions(+), 15 deletions(-) create mode 100755 authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index 301ed081b..861af62a3 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -3,7 +3,7 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib go 1.25.4 require ( - github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 + github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 github.com/envoyproxy/go-control-plane/envoy v1.37.0 github.com/fsnotify/fsnotify v1.10.1 github.com/gobwas/glob v0.2.3 diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index bba5a1751..336f5cbb9 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -30,8 +30,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 h1:oKlTk1+q00z0/YdnZUepelzqKa3Xc1wB+o6//L4DsHM= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 h1:BDal9uwZ0tN92q3zfv2sXxlC0LYOPLiHz0wBzhJJKd0= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/authbridge/authlib/plugins/cpex/manager_test.go b/authbridge/authlib/plugins/cpex/manager_test.go index 163c53529..a9bbd5464 100644 --- a/authbridge/authlib/plugins/cpex/manager_test.go +++ b/authbridge/authlib/plugins/cpex/manager_test.go @@ -44,9 +44,9 @@ type FakeManager struct { // Recorded state — read after the test action. LoadedYAML string - Initialized bool + Initialized bool ShutdownCalled bool - Invokes []FakeInvoke + Invokes []FakeInvoke } // FakeInvoke records one Invoke call on a FakeManager. diff --git a/authbridge/authlib/plugins/cpex/plugin.go b/authbridge/authlib/plugins/cpex/plugin.go index 488f2bbae..e9e84aaeb 100644 --- a/authbridge/authlib/plugins/cpex/plugin.go +++ b/authbridge/authlib/plugins/cpex/plugin.go @@ -169,6 +169,18 @@ func (p *CPEX) Configure(raw json.RawMessage) error { p.bypassPaths = matcher p.bypassHosts = c.BypassHosts + // bypass_hosts is honored on the outbound phase only and ignored on the + // inbound reverse-proxy phase, where the Host header is attacker- + // controlled and identity is resolved inside the bypassed chain (Finding + // 1). Direction isn't known at Configure time (one instance can serve + // either phase), so warn unconditionally when it's set rather than + // rejecting the config. + if len(c.BypassHosts) > 0 { + slog.Warn("cpex: bypass_hosts is honored on the outbound phase only; "+ + "it is ignored on the inbound reverse-proxy phase where the Host "+ + "header is attacker-controlled", "bypass_hosts", c.BypassHosts) + } + factory := p.newManager if factory == nil { factory = NewManager @@ -290,7 +302,12 @@ func (p *CPEX) runHooks(ctx context.Context, pctx *pipeline.Context, hooks []str pctx.Skip("path_bypass") return pipeline.Action{Type: pipeline.Continue} } - if matchesAnyHost(p.bypassHosts, pctx.Host) { + // bypass_hosts is honored on OUTBOUND only. On the inbound reverse-proxy + // phase the Host header is attacker-controlled and identity has NOT been + // pre-validated (it is resolved inside this very chain), so a spoofed Host + // must not skip CPEX. Path bypass (above) still applies inbound for + // health/discovery. See security review Finding 1. + if pctx.Direction == pipeline.Outbound && matchesAnyHost(p.bypassHosts, pctx.Host) { pctx.Skip("host_bypass") return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/cpex/plugin_test.go b/authbridge/authlib/plugins/cpex/plugin_test.go index 0a69a2faf..b0e647980 100644 --- a/authbridge/authlib/plugins/cpex/plugin_test.go +++ b/authbridge/authlib/plugins/cpex/plugin_test.go @@ -566,7 +566,7 @@ func TestDispatch_PathBypassSkipsInvoke(t *testing.T) { } } -func TestDispatch_HostBypassSkipsInvoke(t *testing.T) { +func TestDispatch_HostBypassSkipsInvokeOutbound(t *testing.T) { fake := &FakeManager{ KnownHooks: []string{HookToolPreInvoke}, Hooks: map[string]Result{ @@ -574,9 +574,10 @@ func TestDispatch_HostBypassSkipsInvoke(t *testing.T) { }, } // keycloak.* is in defaultBypassHosts; port stripping handles - // "keycloak.local:8081" cleanly. + // "keycloak.local:8081" cleanly. Host bypass is honored on OUTBOUND + // only (Finding 1), so the direction must be set for it to apply. p := setupAndInit(t, fake, cfgOneRequestHook) - pctx := &pipeline.Context{Host: "keycloak.local:8081"} + pctx := &pipeline.Context{Direction: pipeline.Outbound, Host: "keycloak.local:8081"} a := p.OnRequest(context.Background(), pctx) if a.Type != pipeline.Continue { t.Fatalf("Type = %d, want Continue (host bypass)", a.Type) @@ -586,6 +587,29 @@ func TestDispatch_HostBypassSkipsInvoke(t *testing.T) { } } +// Finding 1: on the inbound reverse-proxy phase the Host header is +// attacker-controlled and identity is resolved inside the CPEX chain, so a +// spoofed Host matching bypass_hosts must NOT skip CPEX. The hook still fires. +func TestDispatch_HostBypassIgnoredInbound(t *testing.T) { + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionDeny, Reason: "policy says no"}, + }, + } + p := setupAndInit(t, fake, cfgOneRequestHook) + // Same bypass-matching Host as the outbound test, but inbound: the + // gate is skipped and the (deny) hook runs. + pctx := &pipeline.Context{Direction: pipeline.Inbound, Host: "keycloak.local:8081"} + a := p.OnRequest(context.Background(), pctx) + if a.Type != pipeline.Reject { + t.Fatalf("Type = %d, want Reject (inbound must not honor host bypass)", a.Type) + } + if len(fake.Invokes) != 1 { + t.Fatalf("Invoke called %d times, want 1 (inbound must not skip CPEX on a spoofable Host)", len(fake.Invokes)) + } +} + func TestConfigure_CustomBypassListsHonored(t *testing.T) { fake := &FakeManager{ KnownHooks: []string{HookToolPreInvoke}, diff --git a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION index f945facd4..901f7c060 100644 --- a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION @@ -1 +1 @@ -v0.2.0-ffi.test.7 +v0.2.0-ffi.test.8 diff --git a/authbridge/cmd/authbridge-cpex/go.mod b/authbridge/cmd/authbridge-cpex/go.mod index 45c9ead55..21850a73b 100644 --- a/authbridge/cmd/authbridge-cpex/go.mod +++ b/authbridge/cmd/authbridge-cpex/go.mod @@ -6,7 +6,7 @@ require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101 require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 // indirect + github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/authbridge/cmd/authbridge-cpex/go.sum b/authbridge/cmd/authbridge-cpex/go.sum index 08d1043fb..b40cdfff7 100644 --- a/authbridge/cmd/authbridge-cpex/go.sum +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -2,8 +2,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7 h1:oKlTk1+q00z0/YdnZUepelzqKa3Xc1wB+o6//L4DsHM= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.7/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 h1:BDal9uwZ0tN92q3zfv2sXxlC0LYOPLiHz0wBzhJJKd0= +github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile index 345a0b7e2..590127c73 100644 --- a/authbridge/demos/hr-cpex/Makefile +++ b/authbridge/demos/hr-cpex/Makefile @@ -100,8 +100,8 @@ port-forward: ## Forward Keycloak (8081) + gateway (8082) to localhost kubectl -n $(NAMESPACE) port-forward svc/authbridge-cpex 8082:8080 & \ wait -scenarios: ## Run all 8 demo curl scenarios (assumes port-forward up) - @for s in $(CURDIR)/scenarios/0[1-8]-*.sh; do \ +scenarios: ## Run all demo curl scenarios (assumes port-forward up) + @for s in $(CURDIR)/scenarios/0[1-9]-*.sh; do \ GATEWAY=http://localhost:8082 \ KEYCLOAK_HOST=http://localhost:8081 \ "$$s"; \ diff --git a/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh b/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh new file mode 100755 index 000000000..259b471ef --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# +# Cross-principal session-taint isolation (security review Finding 2). +# +# Session-scoped taint (scenario 08) persists in CPEX's session store +# keyed by the session id AuthBridge threads from the X-Session-Id +# header. If that key were the raw header value, ANY caller could read +# or pollute another principal's session labels just by reusing their +# session id — e.g. send mail "from" a session someone else tainted, or +# escape a deny by guessing a clean id. The fix (../cpex +# session_resolver.rs) binds the key to the authenticated subject: +# sha256(subject_id : X-Session-Id). So the SAME session id under two +# different users resolves to two different buckets. +# +# This scenario proves it with two principals sharing ONE session id: +# victim = eve (role.hr → may call get_compensation, taints session) +# attacker = bob (perm.email_send → may call send_email) +# +# Steps: +# S1 bob → send_email, fresh session, clean body → 200 (baseline) +# S2 eve → get_compensation, session SHARED → 200 (taints EVE's +# session "secret") +# S3 bob → send_email, SAME SHARED session, clean → 200 (bob does NOT +# inherit eve's +# taint — the proof) +# +# Contrast with scenario 08, where bob reusing his OWN tainted session +# is denied (cpex.session_tainted_secret). Same id, different subject = +# different outcome. Pre-fix, S3 would have returned 403. +# +# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/authbridge-cpex + +set -euo pipefail +source "$(dirname "$0")/_lib.sh" + +EVE=$(mint eve) +BOB=$(mint bob) +CLIENT=$(mint hr-copilot) + +CLEAN_BODY="Quarterly planning sync moved to Thursday." +SID_BASELINE="xprin-baseline-$$-${RANDOM}" +SID_SHARED="xprin-shared-$$-${RANDOM}" + +step "S1 · Bob → send_email (fresh session, clean body)" +note "Session: $SID_BASELINE (never touched secret data)" +note "Expected: 200 OK — baseline, bob can send mail from a clean session" +SESSION_ID="$SID_BASELINE" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY" + +step "S2 · Eve → get_compensation (taints EVE's session)" +note "Session: $SID_SHARED (this id is bound to eve's subject)" +note "Expected: 200 OK — eve has role.hr; taint(secret, session) marks H(eve:$SID_SHARED)" +SESSION_ID="$SID_SHARED" call_get_compensation "$EVE" "$CLIENT" true + +step "S3 · Bob → send_email, SAME session id as eve used, clean body" +note "Session: $SID_SHARED (same string — but bound to bob's subject now)" +note "Expected: 200 OK — bob's key H(bob:$SID_SHARED) != eve's H(eve:$SID_SHARED)," +note "so bob does NOT inherit eve's \"secret\" taint. THIS is the Finding 2 fix." +note "Pre-fix (raw session key) this would have been 403 cpex.session_tainted_secret." +SESSION_ID="$SID_SHARED" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY" From a490a8214801bb0873437823a9c99e3670f6e4df Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 11:59:30 -0400 Subject: [PATCH 03/18] refactor: update hr-cpex demo to deploy authbridge as a sidecar to the agent; addded readme to cpex plugin Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/README.md | 273 +++++++++ authbridge/demos/hr-cpex/Makefile | 94 ++- authbridge/demos/hr-cpex/README.md | 261 ++++++--- .../demos/hr-cpex/agent/CHAT-WALKTHROUGH.md | 27 +- authbridge/demos/hr-cpex/agent/Dockerfile | 26 + authbridge/demos/hr-cpex/agent/agent.py | 505 ++++++++++++++++ authbridge/demos/hr-cpex/agent/chat.py | 552 ++++++------------ .../hr-cpex/agent/requirements-client.txt | 17 + .../demos/hr-cpex/agent/requirements.txt | 36 +- authbridge/demos/hr-cpex/k8s/30-agent.yaml | 218 +++++++ .../demos/hr-cpex/k8s/30-authbridge-cpex.yaml | 148 ----- .../scenarios/04-alice-internal-allow.sh | 2 +- .../scenarios/05-alice-external-cedar-deny.sh | 2 +- .../hr-cpex/scenarios/06-bob-apl-deny.sh | 2 +- .../hr-cpex/scenarios/07-bob-pii-deny.sh | 2 +- .../hr-cpex/scenarios/08-bob-taint-deny.sh | 2 +- .../09-cross-principal-taint-isolation.sh | 2 +- authbridge/demos/hr-cpex/scenarios/_lib.sh | 16 +- 18 files changed, 1527 insertions(+), 658 deletions(-) create mode 100644 authbridge/authlib/plugins/cpex/README.md create mode 100644 authbridge/demos/hr-cpex/agent/Dockerfile create mode 100644 authbridge/demos/hr-cpex/agent/agent.py create mode 100644 authbridge/demos/hr-cpex/agent/requirements-client.txt create mode 100644 authbridge/demos/hr-cpex/k8s/30-agent.yaml delete mode 100644 authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml diff --git a/authbridge/authlib/plugins/cpex/README.md b/authbridge/authlib/plugins/cpex/README.md new file mode 100644 index 000000000..4c471eaa9 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/README.md @@ -0,0 +1,273 @@ +# CPEX + +The CPEX plugin routes AuthBridge pipeline hooks through the [CPEX](https://github.com/contextforge-org/cpex) framework, +so operators can define authorization flows inline and declaratively, and turn decisions into ordered effects (including invoking CPEX plugins). + +## Why CPEX + +PDPs like OPA and Cedar are designed for stateless policy decisions (allow/deny). +CPEX leverages PDPs and organizes policy decisions for agentic workloads. +It calls your PDPs at the level of agentic entities (tools, prompts, models, A2A methods), +composes their verdicts with custom policy written inline, and turns each decision into ordered effects. + +That orchestration allows you to see the full scope of your authorization flow (not obfuscated in code, not hidden behind an API): + +- **Cross-call session reasoning.** An agent reads compensation data, then sends + an email. Each call is allowed on its own. CPEX carries session labels across + calls, so a policy can see the sequence and block the exfiltration. +- **Pre and post-result enforcement.** A tool returns a bulk directory dump the gateway + never saw before the call fired. The pre-invoke check ran before the result + existed, so CPEX adds a post-result phase that evaluates the actual response. +- **Ordered effects beyond allow/deny.** A decision drives concrete actions: + redact a response field, call a guardrail, write an audit record, in a defined order. + A PDP's allow or deny becomes the trigger for these effects through `on_allow` / `on_deny`. + +## How it works + +AuthBridge parses traffic into a typed pipeline context ([inspired by CPEX's specification](https://github.com/kagenti/kagenti-extensions/commit/4c53164d02809ddb19f3b79b3abf9c288a8bc4fb)). +The cpex plugin projects that context into CMF (a normalized, typed policy context), invokes the +CPEX hooks the operator configured, applies any modifications CPEX returns +(redacted bodies, mutated headers, session labels), and maps the CPEX outcome +back into a pipeline action. + +``` +request ─► token-exchange ─► parsers ─► cpex ─────────► upstream ─► response + (MCP, │ + inference, │ context + A2A) ▼ + CMF what you evaluate + │ + ▼ + APL how you define policy +``` + +- **CMF** is what you evaluate: a typed view of identity, content, and metadata. +- **APL** is how you define policy: the DSL plus named CPEX plugins. +- **cpex** is where enforcement happens in the AuthBridge pipeline. + +The plugin fires the CPEX hooks listed in its `hooks` block on each phase. Hooks +run in declaration order and the chain short-circuits on the first sub-plugin +that returns deny. An empty hook list makes the phase a no-op, so you can install +the plugin and ship a policy update later. + +### Build tag + +The CPEX backend uses cgo and links `libcpex_ffi.a`. Only the `authbridge-cpex` +binary compiles this package with `-tags cpex`; the `authbridge-proxy`, +`authbridge-envoy`, and `authbridge-lite` binaries do not import it. Configuring +the `cpex` plugin in any other binary fails at boot with a clear "build the +cpex binary" error rather than registering a silent no-op. The package itself +compiles tag-free for unit tests via a fake manager, so +`go test ./authlib/plugins/cpex/...` runs with `CGO_ENABLED=0`. + +## CPEX hooks + +AuthBridge dispatches operator-selected subsets of these hooks on each phase. The +constants live in `hooks.go`. + +| Hook | Fires | APL home | +|---|---|---| +| `cmf.tool_pre_invoke` | before an MCP tool call is forwarded | tool-args validation, PDP checks, delegation | +| `cmf.tool_post_invoke` | after a tool call returns, before it reaches the agent | post-result checks, audit | +| `cmf.llm_input` | on an LLM request before it leaves the pod | prompt PII redaction | +| `cmf.llm_output` | on an LLM response before it reaches the agent | output redaction, audit | + +CPEX also runs its own `identity.resolve` hook ahead of the entity hooks when +identity resolvers are wired. AuthBridge calls the fused resolve-then-invoke path +so resolved credentials reach delegation in CPEX memory. + +## CPEX plugins + +APL routes pull from a toolbox of named CPEX sub-plugins declared in the CPEX +YAML. Routes invoke them by name through APL steps: `plugin(pii-scan)`, +`delegate(workday-oauth, ...)`, `cedar: { ... }`. See the HR demo for examples. + +## CPEX APL + +APL is a per-entity policy that orchestrates ABAC and ReBAC predicates, ordered effects, and +sequencing, over the common CMF vocabulary. A route binds a policy to one entity +(a tool, a prompt, a model, an A2A method). From the HR demo: + +```yaml +routes: + - tool: get_compensation + apl: + policy: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" + - "taint(secret, session)" # session label persists across calls + - "plugin(audit-log)" + args: + ssn: "str | redact(!perm.view_ssn)" # redact request arg when caller lacks the perm + result: + ssn: "str | redact(!perm.view_ssn)" # redact the response field too + + - tool: send_email + apl: + policy: + - "require(perm.email_send)" + - "plugin(pii-scan)" + - "security.labels contains \"secret\": deny('external email blocked', 'session_tainted_secret')" + - "plugin(audit-log)" +``` + +`get_compensation` taints the session with `secret`. The label persists in the +CPEX session store keyed by the session id, threaded from `X-Session-Id`. A later +`send_email` in the same session reads that label and denies, blocking the +exfiltration at the session level rather than with a per-call filter. + +### External PDPs (BYOP) + +APL steps can call out to external policy decision points and act on their +verdict with the same effect vocabulary: + +```yaml +policy: + - require(authenticated) + - opa("http://opa:8181/v1/data/hr/compensation/deny"): + on_deny: + - deny + - taint(compensation_violation, session) + - plugin(audit-log) + - cedar: + action: "Jans::Action::Read" + resource_type: "Jans::CompensationRecord" + on_deny: [deny, plugin(compliance_alert)] + - authzen("https://authz.corp.com/access/v1/evaluation"): + on_deny: [deny] +``` + +Local structural checks run fast and first; external PDPs run when the local +checks pass; their decisions feed the same `on_deny` / `on_allow` effect lists. + +## CMF / extension mapping + +The AuthBridge pipeline context is mapped into the CMF Message plus +typed extensions that CPEX policies read. + +| AuthBridge source | CPEX CMF / extension target | Notes | +|---|---|---| +| MCP `tools/call`, `prompts/get`, `resources/read`, result | `ContentPart` tool_call / prompt_request / resource_ref / tool_result | structured; rewritable on write-back | +| MCP tool / prompt / resource name | `MetaExtension.{EntityType, EntityName}` | drives per-route dispatch | +| Inference request messages | CMF text parts + `AgentExtension.Conversation.History` | drives `cmf.llm_input` | +| Inference model | `LLMExtension.{ModelID, Provider}` | provider derived from model id | +| Inference finish reason + token usage | `CompletionExtension.{StopReason, Tokens, Model}` | response phase | +| A2A text parts | CMF text parts | role from phase | +| A2A method | `MetaExtension.{EntityType: a2a_method, EntityName}` | drives per-route dispatch | +| A2A session / task / message id | `AgentExtension.{SessionID, ConversationID}` + `ProvenanceExtension.MessageID` | | +| Identity subject + scopes | `SecurityExtension.Subject.{ID, Roles}` | always mapped | +| Identity auth method + curated claims | `SecurityExtension.AuthMethod`, `Subject.Claims` | issuer / audience / exp, not the raw claim map | +| Agent workload identity | `SecurityExtension.Agent.{ClientID, WorkloadID, TrustDomain}` | the agent's own identity, distinct from the caller | +| Delegation chain | `DelegationExtension.{Chain, Depth, OriginSubjectID, ActorSubjectID}` | populated by token exchange | +| Request id / trace headers | `RequestExtension.{RequestID, TraceID, SpanID}` | from `X-Request-Id` / B3 / traceparent | +| Session id | `AgentExtension.SessionID` | from A2A natively or `X-Session-Id` | +| HTTP headers | `HttpExtension.RequestHeaders` | Authorization and Cookie stripped | +| `pctx.Extensions.Custom["cpex/"]` | `Extensions.Custom[""]` | only the `cpex/`-prefixed entries cross | + +Caller identity lives in `SecurityExtension.Subject`. Policies read it from +`security.subject`, not `agent.agent_id`. The `agent` slot models the agent's own +execution identity. + +CMF extension slots are visible to all plugins (unrestricted) or require a +declared capability (capability-gated). `http`, `security`, `agent`, and +`delegation` are gated; a plugin must hold `read_headers`, `read_agent`, +`read_delegation`, and so on to see them. Sensitive headers are stripped at the +AuthBridge boundary before the slot is built, so a misconfigured grant or the +unauthenticated session API cannot leak cookies or api-keys. + +## Pipeline configuration + +Place `cpex` after the protocol parsers and any content filters on the phase you +want to enforce. The parsers populate `pctx.Extensions`; cpex reads from there to +build CMF. The plugin declares `RequiresAny: [mcp-parser, inference-parser, +a2a-parser]`, so `pipeline.Build` fails fast if no parser feeds it. + +The HR demo enforces on the agent's egress (the forward proxy), with inbound left +as passthrough: + +``` +outbound: mcp-parser ──► cpex ──► (token exchange happens as part of the policy via delegate) +``` + +```yaml +pipeline: + inbound: + plugins: [] # passthrough; forward identity headers to the agent + outbound: + plugins: + - name: mcp-parser # populates pctx.Extensions.MCP + - name: cpex + config: + hooks: + on_request: [cmf.tool_pre_invoke] + on_response: [cmf.tool_post_invoke] + config_file: /etc/cpex/cpex.yaml + fail_open: false +``` + +Identity resolution, policy, delegation, redaction, PII scanning, and session +taint all run inside cpex via its own sub-plugins, so the AuthBridge-side +`jwt-validation` and `token-exchange` plugins are not needed on this path. + +### Plugin config + +| Field | Default | Purpose | +|---|---|---| +| `hooks.on_request` | empty | CPEX hooks fired during OnRequest, in order | +| `hooks.on_response` | empty | CPEX hooks fired during OnResponse, in order | +| `config` | empty | CPEX runtime YAML inline; mutually exclusive with `config_file` | +| `config_file` | empty | path to CPEX YAML; mounted from a ConfigMap in production | +| `fail_open` | `false` | on a CPEX internal error: deny with 502 (false) or continue (true) | +| `worker_threads` | `0` | CPEX tokio worker pool size; 0 lets CPEX pick | +| `bypass_paths` | health + `.well-known` | URL path globs that skip CPEX entirely | +| `bypass_hosts` | keycloak / SPIRE / observability | host globs that skip CPEX, outbound only | + +A `cpex` plugin with no hooks, no config, and no config file is installed but +inert. Configure succeeds and both phases return continue. `config` and +`config_file` carry the CPEX YAML verbatim, exactly what CPEX's own docs +describe, with no AuthBridge-side reshaping. Unknown config keys are rejected at +boot. + +`bypass_hosts` is honored on the outbound phase only. On the inbound +reverse-proxy phase the Host header is attacker-controlled and identity is +resolved inside the bypassed chain, so a spoofed Host must not skip CPEX. +`bypass_paths` applies on both phases. Neither list accepts `*` or empty; to +disable the plugin, remove it from the pipeline. + +## Decisions and denials + +The plugin maps the CPEX outcome onto AuthBridge's five-value invocation +vocabulary: + +- **allow**: all sub-plugins continued, request proceeds untouched. +- **deny**: a sub-plugin returned a policy violation. Rejects with HTTP 403 and a + `cpex.` violation code (`cpex.pii_detected`, + `cpex.session_tainted_secret`, and so on). +- **modify**: policy rewrote the body, headers, or labels. Changes are applied, + the request continues. +- **observe**: audit-only outcome, recorded, request continues. + +A CPEX policy `deny` is a normal outcome and is always honored. `fail_open` only +governs CPEX-internal failures (an FFI error, an unreachable backend, a +modification that cannot be applied). With `fail_open: false` those reject with +HTTP 502; with `true` they log and continue. The default is fail-closed because a +CPEX error usually means a misconfigured policy or an unreachable PDP, and +silently allowing traffic in that state is rarely intended. + +## Building and testing + +```sh +# Unit tests (no cgo) cover every CMF mapping and re-serializer via a fake manager +cd authbridge/authlib && CGO_ENABLED=0 go test ./plugins/cpex/... + +# Build the real backend: links libcpex_ffi.a, -tags cpex, CGO on +cd authbridge && podman build -f cmd/authbridge-cpex/Dockerfile -t authbridge-cpex:latest . +``` + +The pinned CPEX FFI ABI version lives in `cmd/authbridge-cpex/CPEX_FFI_VERSION`. + +## See also + +- `authbridge/demos/hr-cpex` — end-to-end demo with Bob, Eve, and Alice personas + covering the Workday flow, GitHub flow with Cedar, PII scanning, and session + taint propagation. diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile index 590127c73..cfdfee785 100644 --- a/authbridge/demos/hr-cpex/Makefile +++ b/authbridge/demos/hr-cpex/Makefile @@ -3,15 +3,18 @@ # End-to-end: # # make deploy # build images, load into kind, kubectl apply -# make port-forward # expose Keycloak (8081) + gateway (8082) on host -# make scenarios # run all 8 demo curl scenarios -# make logs # tail authbridge-cpex logs +# make port-forward # Keycloak (8081) + agent (8082) + fwd proxy (8083) + session API (9094) +# make scenarios # run all 9 demo curl scenarios (through the forward proxy) +# make logs # tail the authbridge-cpex sidecar logs # make undeploy # delete the cpex-demo namespace # # Prerequisites: # - kind cluster (we target `kagenti`; override KIND_CLUSTER) # - docker / podman # - kubectl +# - (chat only) host Ollama on 0.0.0.0 + the right LLM_API_BASE — see the +# "Agent LLM wiring" block below and `make ollama-check`. The curl +# `scenarios` need NO LLM. # All source needed for the demo lives in this directory tree # (hr-mcp-server/, k8s/, agent/). @@ -22,15 +25,29 @@ NAMESPACE ?= cpex-demo # registry. imagePullPolicy: Never in the Deployment manifests. AUTHBRIDGE_IMAGE ?= authbridge-cpex:dev HR_MCP_IMAGE ?= hr-mcp:dev +AGENT_IMAGE ?= hr-cpex-agent:dev # Source paths for the images. authbridge-cpex builds from the # authbridge module (cmd/ + authlib/, two levels up from this demo -# dir); hr-mcp is vendored alongside the manifests so the demo is -# self-contained. +# dir); hr-mcp and the agent are vendored alongside the manifests so +# the demo is self-contained. AUTHBRIDGE_ROOT := $(abspath $(CURDIR)/../..) HR_MCP_SRC ?= $(CURDIR)/hr-mcp-server +AGENT_SRC ?= $(CURDIR)/agent -.PHONY: help build-images load-images deploy port-forward scenarios logs undeploy preflight +# Agent LLM wiring (injected into the deployment on `apply`). The agent's +# inference goes DIRECT to this base (never through the cpex sidecar). +# +# LLM_API_BASE — how a cluster pod reaches the host's Ollama: +# * Docker Desktop / kind-with-host-mappings: http://host.docker.internal:11434 (default) +# * Rancher Desktop (lima): http://192.168.5.2:11434 (lima host-gateway) +# In BOTH cases Ollama must listen beyond loopback: OLLAMA_HOST=0.0.0.0 (restart Ollama). +# Override per-env, e.g.: make deploy LLM_API_BASE=http://192.168.5.2:11434 +# MODEL — any litellm-routed model the host Ollama has pulled. +LLM_API_BASE ?= http://host.docker.internal:11434 +MODEL ?= ollama/llama3.2:3b + +.PHONY: help build-images load-images apply deploy port-forward scenarios logs undeploy preflight ollama-check .DEFAULT_GOAL := help @@ -50,9 +67,10 @@ preflight: ## Check that prerequisites are in place @command -v docker >/dev/null || { echo "missing: docker"; exit 1; } @kind get clusters | grep -q '^$(KIND_CLUSTER)$$' || { echo "missing kind cluster: $(KIND_CLUSTER)"; exit 1; } @test -d "$(HR_MCP_SRC)" || { echo "missing: $(HR_MCP_SRC) (should ship in the demo dir)"; exit 1; } + @test -d "$(AGENT_SRC)" || { echo "missing: $(AGENT_SRC) (should ship in the demo dir)"; exit 1; } @echo "preflight OK" -build-images: preflight ## Build authbridge-cpex + hr-mcp docker images +build-images: preflight ## Build authbridge-cpex + hr-mcp + agent docker images @echo "[build] authbridge-cpex from $(AUTHBRIDGE_ROOT)" cd $(AUTHBRIDGE_ROOT) && docker build \ -f cmd/authbridge-cpex/Dockerfile \ @@ -60,10 +78,13 @@ build-images: preflight ## Build authbridge-cpex + hr-mcp docker images -t $(AUTHBRIDGE_IMAGE) . @echo "[build] hr-mcp from $(HR_MCP_SRC)" cd $(HR_MCP_SRC) && docker build -t $(HR_MCP_IMAGE) . + @echo "[build] hr-cpex-agent from $(AGENT_SRC)" + cd $(AGENT_SRC) && docker build -t $(AGENT_IMAGE) . -load-images: preflight ## Load both images into the kind cluster +load-images: preflight ## Load all three images into the kind cluster kind load docker-image $(AUTHBRIDGE_IMAGE) --name $(KIND_CLUSTER) kind load docker-image $(HR_MCP_IMAGE) --name $(KIND_CLUSTER) + kind load docker-image $(AGENT_IMAGE) --name $(KIND_CLUSTER) apply: ## kubectl apply the manifests (assumes images already loaded) kubectl apply -f k8s/00-namespace.yaml @@ -81,34 +102,69 @@ apply: ## kubectl apply the manifests (assumes images already loaded) --dry-run=client -o yaml | kubectl apply -f - kubectl apply -f k8s/10-keycloak.yaml kubectl apply -f k8s/20-hr-mcp.yaml - kubectl apply -f k8s/30-authbridge-cpex.yaml + kubectl apply -f k8s/30-agent.yaml + # Inject the env-specific LLM wiring. Idempotent: when these match the + # manifest defaults (Docker Desktop case) kubectl reports no change and + # does not roll the pod; override LLM_API_BASE for Rancher Desktop. + kubectl -n $(NAMESPACE) set env deployment/hr-cpex-agent -c agent \ + LLM_API_BASE='$(LLM_API_BASE)' MODEL='$(MODEL)' deploy: build-images load-images apply ## Full bring-up: build, load, apply @echo - @echo "Waiting for pods to become Ready..." - kubectl -n $(NAMESPACE) wait --for=condition=Ready pod -l app.kubernetes.io/part-of=cpex-demo --timeout=120s + @echo "Waiting for rollouts to finish..." + @# rollout status follows the new ReplicaSet, so it's correct even when the + @# `set env` in `apply` rolls the agent pod (a plain label wait can race a + @# terminating old pod against the new one). + kubectl -n $(NAMESPACE) rollout status deployment/keycloak --timeout=180s + kubectl -n $(NAMESPACE) rollout status deployment/hr-mcp --timeout=120s + kubectl -n $(NAMESPACE) rollout status deployment/hr-cpex-agent --timeout=120s @echo @printf "\033[1;32mDeploy complete.\033[0m Next:\n" @printf " make port-forward # in another terminal\n" - @printf " make scenarios\n" + @printf " make scenarios # deterministic curl matrix (no LLM needed)\n" + @printf " make ollama-check # only for the interactive chat — confirms a pod can reach Ollama\n" -port-forward: ## Forward Keycloak (8081) + gateway (8082) to localhost +port-forward: ## Forward Keycloak (8081) + agent (8082) + fwd proxy (8083) + session API (9094) @echo "Port-forwarding (Ctrl+C to stop)..." - @echo " Keycloak admin: http://localhost:8081 (admin/admin)" - @echo " Gateway: http://localhost:8082" + @echo " Keycloak admin: http://localhost:8081 (admin/admin)" + @echo " Agent (A2A): http://localhost:8082 <- chat.py talks here" + @echo " Sidecar fwd proxy: http://localhost:8083 <- scenarios curl -x here" + @echo " CPEX session API: http://localhost:9094 <- inspect taint/sessions" + @# Keycloak + agent go via Service; the forward proxy (8081) and session + @# API (9094) bind to localhost in the pod, so we port-forward those + @# against the deployment's pod directly (8083 on the host avoids the + @# Keycloak 8081 collision). kubectl -n $(NAMESPACE) port-forward svc/keycloak 8081:8080 & \ - kubectl -n $(NAMESPACE) port-forward svc/authbridge-cpex 8082:8080 & \ + kubectl -n $(NAMESPACE) port-forward svc/hr-cpex-agent 8082:8080 & \ + kubectl -n $(NAMESPACE) port-forward deploy/hr-cpex-agent 8083:8081 9094:9094 & \ wait scenarios: ## Run all demo curl scenarios (assumes port-forward up) @for s in $(CURDIR)/scenarios/0[1-9]-*.sh; do \ - GATEWAY=http://localhost:8082 \ + PROXY=http://localhost:8083 \ KEYCLOAK_HOST=http://localhost:8081 \ "$$s"; \ done -logs: ## Tail authbridge-cpex logs - kubectl -n $(NAMESPACE) logs -f deploy/authbridge-cpex +logs: ## Tail the authbridge-cpex sidecar logs + kubectl -n $(NAMESPACE) logs -f deploy/hr-cpex-agent -c authbridge-cpex + +ollama-check: ## Verify a cluster pod can reach the host Ollama at LLM_API_BASE + @echo "Checking pod -> $(LLM_API_BASE) (the agent's inference endpoint)..." + @kubectl -n $(NAMESPACE) exec deploy/hr-cpex-agent -c agent -- \ + python3 -c "import urllib.request,sys; \ +u='$(LLM_API_BASE)'.rstrip('/')+'/api/tags'; \ +print('OK', urllib.request.urlopen(u, timeout=5).status, u)" \ + || { \ + echo; \ + echo "UNREACHABLE. Two things to check:"; \ + echo " 1) Ollama must listen beyond loopback: OLLAMA_HOST=0.0.0.0 then restart Ollama."; \ + echo " 2) LLM_API_BASE must be the host address your cluster pods can route to:"; \ + echo " Docker Desktop / kind-host-mappings: http://host.docker.internal:11434"; \ + echo " Rancher Desktop (lima): http://192.168.5.2:11434"; \ + echo " Re-deploy with the right one, e.g.: make deploy LLM_API_BASE=http://192.168.5.2:11434"; \ + exit 1; \ + } undeploy: ## Tear down the demo -kubectl delete namespace $(NAMESPACE) --wait=false diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md index 39a0ee786..90bc50a38 100644 --- a/authbridge/demos/hr-cpex/README.md +++ b/authbridge/demos/hr-cpex/README.md @@ -1,9 +1,22 @@ # CPEX Bridge Demo -An AuthBridge sidecar fronts an MCP backend on a Kagenti kind cluster -and enforces authorization with CPEX/APL. - -## Why CPEX/APL, not just a PDP +An LLM **agent** runs as a container exposing **A2A** (`message/send`), +with an **authbridge-cpex sidecar enforcing CPEX/APL on the agent's +outbound tool calls** to an MCP backend, on a Kagenti kind cluster. CPEX +is a transparent **egress guardrail on the agent**: identity, Cedar PDP, +RFC 8693 delegation, redaction, PII scanning, session taint, and audit +all fire when the agent calls a tool — not at a gateway in front of it. + +> **Architecture note.** Earlier revisions of this demo ran CPEX as a +> standalone gateway *in front of* `hr-mcp` (enforcing on the inbound +> path), with the "agent" being a host-side `chat.py` script. It has been +> reworked to the agent-sidecar shape above (matching the `ibac` demo): +> the agent is now containerized, `chat.py` is a thin A2A client, and CPEX +> enforces on the agent's **outbound** path. CPEX is direction-agnostic, +> so the policy in `cpex-policy.yaml` is unchanged — only *where* it runs +> moved (inbound → outbound), expressed in the sidecar's pipeline. + +## Why CPEX A decision engine like OPA or Cedar answers one question: given this input, is the action allowed? That verdict still has to be wired into @@ -13,7 +26,7 @@ sensitive fields from the payload, and writes an audit record, all in the right order with the right short-circuits. That orchestration is normally bespoke code in every gateway. -APL is the connective layer that makes the orchestration itself +CPEX APL is the connective layer that makes the orchestration itself declarative. A policy is a per-resource chain of steps, and those steps compose three things a plain PDP does not: @@ -33,11 +46,6 @@ compose three things a plain PDP does not: below what the call needs. Issuing credentials becomes part of the authorization decision rather than a side effect bolted on afterward. -For AuthBridge, that is the difference between native Go plugins that -each make one fixed decision and an operator-editable policy that -composes identity, any PDP, token exchange, redaction, and audit into a -single declarative chain, shipped as a ConfigMap. - ## What this demo shows 1. **Same request, different data.** Bob (HR, with `view_ssn`) sees an @@ -59,13 +67,21 @@ install Kagenti or create a cluster. You need: - A Kagenti kind cluster named `kagenti`. Install it from the [Kagenti repository][kagenti]. Override `KIND_CLUSTER` in the Makefile if yours has a different name. -- Docker, to build the two local images. They load into kind and are - never pushed to a registry. +- Docker, to build the three local images (`hr-cpex-agent`, + `authbridge-cpex`, `hr-mcp`). They load into kind and are never pushed + to a registry. - kubectl, configured for the cluster. +- An Ollama running on the host with a tool-capable model pulled + (`ollama pull llama3.2:3b`) — only needed for the interactive chat; + the `make scenarios` curl matrix does not use the LLM. Ollama must + listen beyond loopback so cluster pods can reach it: set + `OLLAMA_HOST=0.0.0.0` and restart Ollama. How a pod reaches the host + depends on your Docker provider — see **Pointing the agent at Ollama** + below. Everything else is vendored in this directory: the `hr-mcp` backend, -the AuthBridge manifests, and the chat agent. No sibling checkouts -needed. +the AuthBridge manifests, the agent, and the chat client. No sibling +checkouts needed. The demo creates one namespace, `cpex-demo`. `make undeploy` removes it cleanly and leaves the rest of the cluster untouched. @@ -75,25 +91,33 @@ cleanly and leaves the rest of the cluster untouched. ## Quick start ```bash -make deploy # build images, load into kind, apply manifests, wait for Ready -make port-forward # in another terminal: Keycloak on :8081, gateway on :8082 -make scenarios # run the seven curl scenarios +make deploy # build images (agent + authbridge-cpex + hr-mcp), load into kind, apply, wait for Ready +make port-forward # in another terminal: Keycloak :8081, agent :8082, sidecar forward proxy :8083, session API :9094 +make scenarios # run the nine curl scenarios ``` -`make scenarios` exercises three personas across allow, deny, and -redact paths: +`make scenarios` drives the tool through the agent's **forward-proxy** +(the same egress path the agent's own tool calls take), exercising the +personas across allow, deny, and redact paths: ``` -01-bob-allow.sh PASS SSN visible -02-alice-deny.sh DENY APL require(role.hr) -03-eve-redact.sh PASS SSN redacted in response <- the one to watch -04-alice-internal-allow.sh PASS Cedar permit + token exchange -05-alice-external-cedar-deny.sh DENY Cedar default-deny -06-bob-apl-deny.sh DENY APL team mismatch -07-bob-pii-deny.sh DENY PII validator (PII in the body) -08-bob-taint-deny.sh DENY taint propagation (session touched secrets) +01-bob-allow.sh PASS SSN visible +02-alice-deny.sh DENY APL require(role.hr) +03-eve-redact.sh PASS SSN redacted in response <- the one to watch +04-alice-internal-allow.sh PASS Cedar permit + token exchange +05-alice-external-cedar-deny.sh DENY Cedar default-deny +06-bob-apl-deny.sh DENY APL team mismatch +07-bob-pii-deny.sh DENY PII validator (PII in the body) +08-bob-taint-deny.sh DENY taint propagation (session touched secrets) +09-cross-principal-taint-isolation.sh PASS taint is subject-bound (same id, different user) ``` +The scenarios are deterministic curl runs and skip the LLM entirely: +each posts an MCP `tools/call` through the sidecar forward proxy +(`curl -x http://localhost:8083 http://hr-mcp.cpex-demo:9100/mcp`), +carrying the same `Authorization` + `X-User-Token` (+ `X-Session-Id`) +the agent would. The interactive chat (below) is the full A2A path. + **Act 4 — taint propagation (scenario 08).** Reading compensation runs `taint(secret, session)`, attaching the label `secret` to the CPEX session (persisted in the session store, keyed by the `X-Session-Id` @@ -107,6 +131,16 @@ ids: a clean session sends fine (S1 → 200), reading compensation taints a second session (S2 → 200), and that session can no longer send email (S3 → 403 `cpex.session_tainted_secret`). +**Act 5 — cross-principal taint isolation (scenario 09).** Session taint +is keyed by the authenticated subject, not the raw `X-Session-Id`: the +CPEX session store hashes `sha256(subject_id : session_id)`. So the +*same* session id under two different users resolves to two different +buckets. Scenario 09 proves it — eve taints a shared session id (S2), +but bob reusing that exact id still sends mail (S3 → 200), because +`H(bob:id) ≠ H(eve:id)`. This is why identity must resolve on the +outbound path: the agent re-attaches `X-User-Token` so cpex derives the +subject that scopes (and isolates) taint. + Scenario 03 is the headline. Bob and Eve send the byte-for-byte same request, the same backend returns the same record, but Eve's response comes back without the SSN because the policy redacts it. @@ -121,32 +155,71 @@ comes back without the SSN because the policy redacts it. ## Interactive chat -The scenarios are deterministic curl runs. For a live demo, drive the -gateway with an LLM agent that switches persona mid-conversation. +The scenarios are deterministic curl runs. For a live demo, talk to the +**agent over A2A** with `chat.py` — a thin client that mints persona +tokens and switches persona mid-conversation. The LLM and the tools live +in the agent container; `chat.py` only sends `message/send` and renders +the reply. -Set up the agent once: +The agent itself runs an LLM (default `ollama/llama3.2:3b`). Make sure an +Ollama is running on the host with the model pulled and listening beyond +loopback so the cluster can reach it: + +```bash +ollama pull llama3.2:3b +OLLAMA_HOST=0.0.0.0 ollama serve # or, for the macOS app: + # launchctl setenv OLLAMA_HOST 0.0.0.0 && relaunch Ollama +``` + +See **Pointing the agent at Ollama** below for the per-provider address; +`make ollama-check` verifies a pod can actually reach it. + +Set up the **client** once (its deps are separate from the agent image — +no litellm needed host-side): ```bash cd agent python3 -m venv .venv source .venv/bin/activate -pip install -r requirements.txt +pip install -r requirements-client.txt ``` -Run it against a local Ollama, no API key required: +Run it (the A2A endpoint is the agent Service, exposed on `:8082` by +`make port-forward`): ```bash -ollama pull llama3.1 -GATEWAY_URL=http://localhost:8082/mcp python chat.py --persona eve +python chat.py --persona eve +# or point at a different endpoint: +AGENT_URL=http://localhost:8082 python chat.py --persona bob ``` -The agent defaults to `ollama/llama3.1`. Point `--model` at any -litellm-supported provider for stronger tool-calling: +To run the agent against a non-Ollama model, set `MODEL` (and the +provider's API-key env) on the `agent` container — model selection now +lives in the container, not the client. `MODEL` and `LLM_API_BASE` are +Makefile vars, e.g. `make deploy MODEL=ollama/llama3:70b`. + +### Pointing the agent at Ollama + +The agent's inference call goes **direct** to `LLM_API_BASE` (never +through the cpex sidecar — only the MCP tool call is proxied). How a +cluster pod reaches the host's Ollama depends on your Docker provider, so +`LLM_API_BASE` is a Makefile var (injected into the deployment on +`apply`): + +| Provider | `LLM_API_BASE` | +|---|---| +| Docker Desktop / kind created with host mappings | `http://host.docker.internal:11434` (default) | +| Rancher Desktop (lima) | `http://192.168.5.2:11434` (the lima host-gateway) | ```bash -GATEWAY_URL=http://localhost:8082/mcp python chat.py --persona bob --model gpt-4o-mini +# Rancher Desktop example: +make deploy LLM_API_BASE=http://192.168.5.2:11434 +make ollama-check LLM_API_BASE=http://192.168.5.2:11434 # confirms a pod can reach it ``` +In all cases Ollama must be started with `OLLAMA_HOST=0.0.0.0`. If +`make ollama-check` fails, it prints exactly these two things to fix. + In-chat commands: ``` @@ -181,34 +254,48 @@ See `agent/CHAT-WALKTHROUGH.md` for a longer script and talking points. ## How it works -The client sends one MCP request with two JWTs: a client token in -`Authorization` and the persona's user token in `X-User-Token`. The -AuthBridge sidecar runs a two-stage pipeline, `mcp-parser -> cpex`, -and the `cpex` plugin evaluates `cpex-policy.yaml` in order. +The A2A client sends `message/send` to the agent with two JWTs as HTTP +headers: a client token in `Authorization` and the persona's user token +in `X-User-Token`, plus an `X-Session-Id` that scopes session taint. The +sidecar's **inbound** pipeline is empty (passthrough), so those headers +reach the agent unmodified. The agent runs its LLM loop and, for each +tool call, **re-attaches the same headers** onto an MCP `tools/call` that +egresses through the sidecar's **forward proxy** — where the `mcp-parser +-> cpex` pipeline runs and `cpex` evaluates `cpex-policy.yaml` in order. ``` -chat.py / curl (host) - | POST /mcp - | Authorization: client token -> jwt-client - | X-User-Token: persona token -> jwt-user - v port-forward :8082 -> svc/authbridge-cpex:8080 -+--------------------------------------------------------------+ -| authbridge-cpex pod (namespace: cpex-demo) | -| | -| AuthBridge pipeline: mcp-parser -> cpex | -| mcp-parser: parse JSON-RPC, set mcp.method / mcp.name | -| cpex: run cpex-policy.yaml in order: | -| | -| 1. identity resolve subject + client from the two | -| JWTs, verified against Keycloak JWKS | -| 2. APL policy require(...) -> cedar PDP -> | -| delegate(...) -> post-check | -| 3. plugins pii-scan (deny), audit-log (observe) | -| 4. body redact(args.ssn) / redact(result.ssn) | -+--------------------------------------------------------------+ +chat.py (host A2A client) + | message/send + | Authorization: client token X-User-Token: persona token + | X-Session-Id: conversation id contextId in A2A body + v port-forward :8082 -> svc/hr-cpex-agent:8080 ++------------------------------------------------------------------+ +| hr-cpex-agent pod (namespace: cpex-demo) | +| | +| authbridge-cpex sidecar — reverse proxy :8000 | +| inbound pipeline: [] (passthrough — headers forwarded as-is) | +| v | +| agent :8001 (A2A server + litellm tool loop) | +| - inference: litellm -> host.docker.internal:11434 DIRECT | +| (never through the sidecar — the Ollama footgun) | +| - tool call: POST /mcp, re-attaching Authorization + | +| X-User-Token + X-Session-Id | +| v explicit per-client proxy -> :8081 | +| authbridge-cpex sidecar — forward proxy :8081 | +| outbound pipeline: mcp-parser -> cpex | +| mcp-parser: parse JSON-RPC, set mcp.method / mcp.name | +| cpex: run cpex-policy.yaml in order: | +| 1. identity resolve subject + client from the two JWTs, | +| verified against Keycloak JWKS (subject | +| keys the session-taint bucket) | +| 2. APL policy require(...) -> cedar PDP -> | +| delegate(...) -> post-check | +| 3. plugins pii-scan (deny), audit-log (observe), taint | +| 4. body redact(args.ssn) / redact(result.ssn) | ++------------------------------------------------------------------+ | | - | delegate(): RFC 8693 | on allow: - | token exchange | reverse-proxy + | delegate(): RFC 8693 | on allow: forward proxy + | token exchange | to the MCP target v v Keycloak svc:8080 hr-mcp svc:9100 realm cpex-demo get_compensation @@ -220,6 +307,14 @@ Response-side redaction (`result.ssn`) runs as the reply flows back out through the pipeline, so an SSN the backend returns unsolicited is still stripped for a caller without the permission. +**Why inference must bypass the sidecar.** The agent makes two kinds of +outbound call. The MCP tool call is the one cpex should govern, so it +goes through the forward proxy (`:8081`). The LLM inference call must +*not*: it goes direct to `host.docker.internal:11434`. The container +therefore sets `MCP_PROXY` (used by the MCP client only) and deliberately +**no `HTTP_PROXY`** — a global proxy would drag inference through cpex, +which would try to evaluate model traffic as a tool call. + ### Authorization patterns All authorization lives in the `routes:` block at the bottom of @@ -263,7 +358,7 @@ After editing: ```bash make apply -kubectl -n cpex-demo rollout restart deployment/authbridge-cpex +kubectl -n cpex-demo rollout restart deployment/hr-cpex-agent ``` `make apply` regenerates the ConfigMap but does not restart the pod, @@ -277,13 +372,17 @@ fresh pod that mounts the new policy. | `k8s/00-namespace.yaml` | The `cpex-demo` namespace | | `k8s/10-keycloak.yaml` | Keycloak Service and Deployment | | `k8s/20-hr-mcp.yaml` | hr-mcp backend Service and Deployment | -| `k8s/30-authbridge-cpex.yaml` | AuthBridge config, Service, Deployment | +| `k8s/30-agent.yaml` | Agent + authbridge-cpex sidecar: config, Service, Deployment | | `k8s/realm-export.json` | Keycloak realm: users, clients, mappers | | `k8s/cpex-policy.yaml` | CPEX policy: identity, PDP, delegator, validators, audit | | `hr-mcp-server/` | Vendored FastAPI MCP backend, built into `hr-mcp:dev` | -| `agent/` | Vendored chat agent: `chat.py`, requirements, walkthrough | -| `scenarios/` | The seven curl scenarios run by `make scenarios` | -| `mint-token.sh` | Mints a persona JWT via Keycloak. Used by scenarios and the agent | +| `agent/agent.py` | A2A server + litellm tool loop, built into `hr-cpex-agent:dev` | +| `agent/chat.py` | Host-side A2A client (mints tokens, drives the demo) | +| `agent/requirements.txt` | Agent (server) deps — installed into the image | +| `agent/requirements-client.txt` | Client deps — installed on the host for `chat.py` | +| `agent/Dockerfile` | Builds `hr-cpex-agent:dev` | +| `scenarios/` | The nine curl scenarios run by `make scenarios` | +| `mint-token.sh` | Mints a persona JWT via Keycloak. Used by scenarios | | `verify-token-exchange.sh` | Checks RFC 8693 token exchange against the realm | ## Tear down @@ -296,11 +395,29 @@ make undeploy # deletes the cpex-demo namespace - **Rebuilt an image but the pod still runs the old one.** A rebuild leaves the Deployment spec identical, so `kubectl apply` does not roll - the pod. After `kind load docker-image authbridge-cpex:dev`, force it - with `kubectl -n cpex-demo rollout restart deployment/authbridge-cpex`. -- **Chat agent cannot reach the gateway.** `chat.py` defaults to - `:8090`, but `make port-forward` exposes the gateway on `:8082`. Pass - `GATEWAY_URL=http://localhost:8082/mcp`. + the pod. After `kind load docker-image hr-cpex-agent:dev` (or + `authbridge-cpex:dev`), force it with + `kubectl -n cpex-demo rollout restart deployment/hr-cpex-agent`. +- **Chat client cannot reach the agent.** `chat.py` defaults to + `http://localhost:8082`; `make port-forward` exposes the agent Service + there. Override with `AGENT_URL=http://localhost:8082`. +- **Agent replies with an LLM/connection error** (`name resolution` or + `all connection attempts failed`). The agent can't reach the host + Ollama. Run `make ollama-check` — it tells you the two fixes: + (1) start Ollama with `OLLAMA_HOST=0.0.0.0`, and (2) set `LLM_API_BASE` + to an address your pods can route to (`host.docker.internal:11434` on + Docker Desktop, `192.168.5.2:11434` on Rancher Desktop). See **Pointing + the agent at Ollama**. Inference goes direct (not through the sidecar) + by design. +- **Client gets HTTP 503 "all connection attempts failed".** A stale + `kubectl port-forward` — the tunnel dies when the agent pod rolls (e.g. + after `make deploy`/`set env`). Restart `make port-forward`. +- **Tool calls aren't being enforced / cpex never fires.** cpex now runs + on the agent's **outbound** path. Confirm the MCP call traverses the + forward proxy: `kubectl -n cpex-demo logs deploy/hr-cpex-agent -c + authbridge-cpex`. The agent must re-attach `X-User-Token` + + `Authorization`; without the user token cpex can't resolve a subject + and session taint silently stops scoping. - **Keycloak token issuer.** `KC_HOSTNAME` sets the `iss` claim to `http://keycloak.cpex-demo:8080`. Tokens minted through the port-forward at `localhost:8081` still carry that in-cluster issuer, diff --git a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md index 57cd4b5ed..a8ae7d987 100644 --- a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md +++ b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md @@ -3,6 +3,12 @@ A cheat-sheet of prompts to type into `chat.py` and what each one should demonstrate. Read it line-by-line during the demo; the LLM does the rest. +`chat.py` is now an **A2A client**: it mints persona tokens and sends +`message/send` to the containerized agent. The LLM and the tools live in +the agent; CPEX enforces on the **agent's outbound** tool calls. The +user-visible behavior below is identical to the old standalone gateway — +only the enforcement point moved (gateway-inbound → agent-egress). + ## Setup (once) In one terminal: @@ -15,7 +21,7 @@ make deploy # builds images, loads to kind, applies man In a second terminal: ```bash -make port-forward # exposes Keycloak (:8081) + gateway (:8082) +make port-forward # Keycloak (:8081) + agent (:8082) + fwd proxy (:8083) + session API (:9094) ``` In a third terminal (so the audience can see what reaches the backend): @@ -24,15 +30,17 @@ In a third terminal (so the audience can see what reaches the backend): kubectl -n cpex-demo logs -f deploy/hr-mcp ``` -In a third terminal (the demo itself): +In a fourth terminal (the demo itself): ```bash cd agent -GATEWAY_URL=http://localhost:8082/mcp python chat.py --persona bob +# one-time: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements-client.txt +# Ollama must be running on the host with the model pulled (ollama pull llama3.2:3b). +python chat.py --persona bob # or: AGENT_URL=http://localhost:8082 python chat.py --persona bob ``` -chat.py writes a banner showing persona + model + gateway URL. -You're now talking to the LLM, which is in front of the CPEX gateway. +chat.py writes a banner showing persona + agent endpoint + session. +You're now talking to the agent over A2A; CPEX governs its egress. --- @@ -224,7 +232,7 @@ Bob: send an email to alice@corp.com with the subject "FYI" `read_delegated_tokens` etc.) — observation can't block — and emits a JSON record describing the denied attempt -**In the gateway's logs (`kubectl -n cpex-demo logs -f deploy/authbridge-cpex`):** +**In the sidecar's logs (`kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex`):** ```json {"ts":"2026-…","plugin":"audit-log","source":"cpex-demo-gateway", @@ -268,8 +276,9 @@ record, no plugin coordination required. what hr-mcp logged. Show `aud: workday-api`, signed by Keycloak. - Run `./verify-token-exchange.sh` live — proves RFC 8693 against the actual Keycloak running in docker. -- Edit the cpex.yaml `require(...)` line and `pkill -HUP` the gateway — - the new policy takes effect immediately (the manager reloads). +- Edit `k8s/cpex-policy.yaml`'s `require(...)` line, then `make apply && + kubectl -n cpex-demo rollout restart deploy/hr-cpex-agent` — the fresh + pod mounts the new policy. - Open `realm-export.json` and show the Keycloak v2 STE setup. Compare to MCP's `aud` validation requirement in the authorization spec. @@ -280,6 +289,6 @@ record, no plugin coordination required. | Symptom | Fix | |---|---| | LLM doesn't call tools, just chats | Try a more directive prompt (`call the get_compensation tool with employee_id…`). Llama 8B forgets tools; switch to 70B. | -| All scenarios return 401 | Keycloak didn't finish importing. `docker compose down -v && docker compose up -d`, wait 30s, re-run `verify-token-exchange.sh`. | +| All scenarios return 401 | Keycloak didn't finish importing the realm. Wait for the pod: `kubectl -n cpex-demo get pod -l app.kubernetes.io/name=keycloak -w`, then re-run `verify-token-exchange.sh`. | | Gateway response is a Pingora `PrematureBodyEnd` | The body-rewrite pad logic regressed. Body rewrite is the only place this happens. | | Cedar returns `cedar.default_deny` for an "allow" case | `${args.X}` substitution may have failed — check the gateway log for the resolve error; the bag key it asked for is in the error. | diff --git a/authbridge/demos/hr-cpex/agent/Dockerfile b/authbridge/demos/hr-cpex/agent/Dockerfile new file mode 100644 index 000000000..495f0c431 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/Dockerfile @@ -0,0 +1,26 @@ +# hr-cpex agent — A2A server (a2a-sdk) wrapping a litellm tool-calling +# loop over the hr-mcp tools. Its authbridge-cpex sidecar enforces CPEX +# on the agent's outbound MCP calls. +# +# Built locally and loaded into kind: +# docker build -t hr-cpex-agent:dev . +# kind load docker-image hr-cpex-agent:dev --name kagenti +# The Deployment uses imagePullPolicy: Never so the kubelet takes the +# locally-loaded image rather than pulling from a registry. + +FROM python:3.12-slim + +WORKDIR /app + +# Server deps only (requirements.txt). requirements-client.txt is for the +# host-side chat.py and is intentionally not installed here. +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent.py /app/agent.py + +# A2A server port. The Deployment sets PORT=8001; the sidecar's reverse +# proxy fronts it and the Service exposes the sidecar. +EXPOSE 8001 + +CMD ["python", "agent.py"] diff --git a/authbridge/demos/hr-cpex/agent/agent.py b/authbridge/demos/hr-cpex/agent/agent.py new file mode 100644 index 000000000..35ec18c99 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/agent.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +"""HR copilot agent — exposes A2A `message/send`; CPEX fires on its egress. + +This is the containerized agent for the hr-cpex demo. It speaks A2A +(via the official `a2a-sdk`) on the inbound side and calls the hr-mcp +tools on the outbound side. The authbridge-cpex sidecar enforces CPEX +on the **outbound** path (the agent's tool calls), so the whole policy +chain — identity, Cedar PDP, RFC 8693 delegation, redaction, PII scan, +session taint, audit — runs as a transparent egress guardrail on the +agent rather than at a gateway in front of the tool. + + A2A client (chat.py on the host — mints persona + client tokens) + │ POST message/send headers: X-User-Token, Authorization, + │ X-Session-Id ; contextId in body + ▼ + authbridge-cpex sidecar — reverse proxy :8000 (inbound: passthrough) + ▼ + THIS agent :8001 + │ ① LLM loop: litellm → host.docker.internal:11434 DIRECT, no proxy + │ ② tool call: POST /mcp, re-attaching X-User-Token + + │ Authorization + X-Session-Id from the inbound request + ▼ via explicit per-client proxy → sidecar forward proxy :8081 + authbridge-cpex sidecar — forward proxy (outbound: mcp-parser → cpex) + │ identity (jwt-user ← X-User-Token, jwt-client ← Authorization) + │ Cedar PDP · redact(args.ssn) · pii-scan · session taint + │ delegate(workday/github-oauth) — RFC 8693 → Keycloak + ▼ + hr-mcp :9100 + +Two outbound flows, deliberately handled differently (the Ollama footgun): + + * LLM inference goes **DIRECT** to the model endpoint. We never set a + global HTTP_PROXY on this container — if we did, litellm/httpx would + route inference through the sidecar's forward proxy and cpex would + try (and fail) to evaluate it as a tool call. + * Only the MCP `tools/call` uses an explicit `httpx.Client(proxy=...)` + pointed at the sidecar's forward proxy, so cpex sees exactly the + traffic it's meant to govern. Mirrors the ibac demo's split + (demos/ibac/agent/main.go: callOllama direct, proxiedClient for tools). + +Identity threading is the crux: cpex resolves identity from headers on +the *outbound* request, and the subject it derives keys the session-taint +store. So every tool call must carry the user's X-User-Token and the +client's Authorization forward, and the X-Session-Id that scopes taint. +""" + +import asyncio +import json +import logging +import os +import uuid +from typing import Any + +import httpx +import litellm +import uvicorn +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.apps import A2AStarletteApplication +from a2a.server.events import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from a2a.utils import new_agent_text_message + +logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper()) +log = logging.getLogger("hr-cpex-agent") + +# --------------------------------------------------------------------------- +# Config (env-driven so the Pod manifest owns the wiring) +# --------------------------------------------------------------------------- + +PORT = int(os.environ.get("PORT", "8001")) +# Where the MCP tool lives. The agent POSTs here; httpx routes the call +# through MCP_PROXY (the sidecar forward proxy), so cpex governs it. +MCP_URL = os.environ.get("MCP_URL", "http://hr-mcp.cpex-demo:9100/mcp") +# The sidecar forward proxy. ONLY the MCP client uses this — never as a +# global HTTP_PROXY (that would drag inference through cpex too). +MCP_PROXY = os.environ.get("MCP_PROXY", "http://localhost:8081") +# litellm-routed model. Defaults to local Ollama (no API key). Override +# with any LiteLLM-supported provider via MODEL + the provider's env. +MODEL = os.environ.get("MODEL", "ollama/llama3.2:3b") +# Inference endpoint. For the ollama provider this is the native base +# (litellm appends /api/...). Set to host.docker.internal in-cluster so +# the agent reaches an Ollama running on the developer's laptop. +LLM_API_BASE = os.environ.get("LLM_API_BASE") or None +# What the agent advertises in its agent card as its callable address. +AGENT_PUBLIC_URL = os.environ.get("AGENT_PUBLIC_URL", "http://hr-cpex-agent.cpex-demo:8080/") + +# --------------------------------------------------------------------------- +# Tools + prompt (lifted verbatim from the original chat.py — the gateway +# enforcement story depends on these exact tool shapes, e.g. the `ssn` +# echo-back arg that redaction strips). +# --------------------------------------------------------------------------- + +SYSTEM_PROMPT = ( + "You are an HR assistant for an HR copilot app. Help the user look up " + "employee compensation, view directories, send emails, and similar " + "tasks. Use the provided tools when needed. " + "\n\n" + "How to interpret tool results: " + "\n" + " * If the tool returns a normal result, present the data to the " + "user. If any field's value is the literal string `[REDACTED]`, " + "show it as-is in your answer — that is the gateway's transparent " + "enforcement marker that the field exists but is hidden for this " + "caller. Do NOT apologize or refuse; just include the field with " + "the value `[REDACTED]`. " + "\n" + " * If the tool returns an `error` envelope (a JSON-RPC error " + "with a `code` and `message`), the gateway denied the call. " + "Acknowledge politely without revealing the internal violation " + "code — the user may not have permission for that operation. " + "\n" + " * If the tool returns an `auth_error`, the request failed at " + "the transport layer. Ask the user to re-authenticate." +) + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_compensation", + "description": ( + "Get compensation data for an employee. Returns salary, bonus, department, and optionally SSN." + ), + "parameters": { + "type": "object", + "properties": { + "employee_id": { + "type": "string", + "description": "Employee identifier (e.g., EMP-001234)", + }, + "include_ssn": { + "type": "boolean", + "description": "Whether to include SSN in the response", + "default": False, + }, + "ssn": { + "type": "string", + "description": ( + "An echo-back of the employee's SSN if the caller " + "claims to already know it — this is exactly the " + "kind of field the gateway redacts when the " + "caller lacks the necessary permission." + ), + }, + }, + "required": ["employee_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "display_compensation", + "description": ("Display a compensation summary for the employee (band only, no salary)."), + "parameters": { + "type": "object", + "properties": { + "employee_id": {"type": "string"}, + }, + "required": ["employee_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_directory", + "description": "Get the employee directory listing.", + "parameters": { + "type": "object", + "properties": { + "department": { + "type": "string", + "description": "Optional department filter", + "default": "", + }, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_email", + "description": "Send an email (simulated).", + "parameters": { + "type": "object", + "properties": { + "to": {"type": "string"}, + "subject": {"type": "string"}, + "body": {"type": "string"}, + }, + "required": ["to", "subject", "body"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "search_repos", + "description": ( + "Search the internal GitHub Enterprise for repositories. " + "Filter by name substring and/or visibility. Visibility is " + "one of `internal`, `public`, `external`." + ), + "parameters": { + "type": "object", + "properties": { + "repo_name": { + "type": "string", + "description": "Substring to filter repo names (e.g. 'web-app').", + "default": "", + }, + "visibility": { + "type": "string", + "description": ( + "Repo visibility — `internal` (default), `public`, or `external`. " + "External repos are typically off-limits for engineering." + ), + "enum": ["internal", "public", "external"], + }, + }, + "required": ["visibility"], + }, + }, + }, +] + + +# --------------------------------------------------------------------------- +# MCP tool client — every call flows through the sidecar forward proxy so +# cpex governs it, carrying the caller's identity headers on egress. +# --------------------------------------------------------------------------- + + +def format_tool_response(status: int, data: dict[str, Any]) -> str: + """Convert the MCP/cpex response into something compact the LLM can + read. Same three shapes the gateway returned in the standalone demo: + + * HTTP 200 + {"result": ...} — happy path + * HTTP 200 + {"error": {code, message, data}} — cpex deny + * HTTP 4xx/5xx + plain text — transport/auth + """ + if status == 401: + body = data.get("text") if isinstance(data, dict) else str(data) + return json.dumps({"gateway_status": 401, "auth_error": body}) + if status >= 400: + return json.dumps({"gateway_status": status, "error": data}) + if "error" in data: + err = data["error"] + return json.dumps( + { + "error": err.get("message", "tool error"), + "violation": (err.get("data") or {}).get("violation"), + } + ) + result = data.get("result", {}) + content = result.get("content", []) + text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + combined = "".join(text_parts) + return combined or json.dumps(result) + + +def call_tool( + tool_name: str, + arguments: dict[str, Any], + *, + user_token: str, + client_token: str, + session_id: str, + request_id: int, +) -> tuple[int, dict[str, Any]]: + """POST a single MCP tools/call through the sidecar forward proxy. + + cpex (running on the sidecar's outbound pipeline) reads identity from + the headers we re-attach here: jwt-user from X-User-Token, jwt-client + from Authorization. X-Session-Id scopes the per-conversation taint + bucket; the cpex session store binds it to the resolved subject, so + the same id under a different user is a different bucket. + """ + payload = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + "id": request_id, + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": client_token, # already "Bearer " + "X-User-Token": user_token, + "X-Session-Id": session_id, + } + # Explicit per-client proxy — ONLY this flow goes through cpex. + with httpx.Client(proxy=MCP_PROXY, timeout=30) as client: + resp = client.post(MCP_URL, json=payload, headers=headers) + try: + data = resp.json() + except (json.JSONDecodeError, ValueError): + data = {"text": resp.text} + return resp.status_code, data + + +# --------------------------------------------------------------------------- +# Agent turn — the litellm tool-calling loop (synchronous; reused from the +# original chat.py). Per-conversation message history is keyed by session +# id so a continuing chat keeps context, while a `switch`/`relogin` on the +# client (fresh session id) starts clean — matching the old UX. +# --------------------------------------------------------------------------- + + +class HRAgent: + def __init__(self) -> None: + self._histories: dict[str, list[dict[str, Any]]] = {} + self._request_id = 0 + + def _history(self, session_id: str) -> list[dict[str, Any]]: + hist = self._histories.get(session_id) + if hist is None: + hist = [{"role": "system", "content": SYSTEM_PROMPT}] + self._histories[session_id] = hist + return hist + + def run_turn( + self, + user_text: str, + *, + user_token: str, + client_token: str, + session_id: str, + ) -> str: + """One user turn: LLM (direct) → tool calls (proxied/cpex) → LLM.""" + messages = self._history(session_id) + messages.append({"role": "user", "content": user_text}) + + completion_kwargs: dict[str, Any] = {} + if LLM_API_BASE: + completion_kwargs["api_base"] = LLM_API_BASE + + try: + response = litellm.completion( + model=MODEL, + messages=messages, + tools=TOOLS, + tool_choice="auto", + **completion_kwargs, + ) + except Exception as e: # noqa: BLE001 — surface any LLM error to the user + messages.pop() + log.warning("LLM error: %s", e) + return f"(LLM error: {e})" + + assistant = response.choices[0].message + if not assistant.tool_calls: + text = assistant.content or "(no response)" + messages.append({"role": "assistant", "content": text}) + return text + + # Tool-call path: replay each call through cpex, then summarize. + messages.append(assistant.model_dump()) + for tc in assistant.tool_calls: + fn = tc.function + try: + args = json.loads(fn.arguments) if isinstance(fn.arguments, str) else fn.arguments + except json.JSONDecodeError: + args = {} + self._request_id += 1 + log.info( + "tool call (session=%s): %s(%s)", + session_id, + fn.name, + json.dumps(args, separators=(",", ":")), + ) + status, data = call_tool( + fn.name, + args, + user_token=user_token, + client_token=client_token, + session_id=session_id, + request_id=self._request_id, + ) + tool_text = format_tool_response(status, data) + log.info("tool result (session=%s, http=%s): %s", session_id, status, tool_text) + messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_text}) + + try: + final = litellm.completion(model=MODEL, messages=messages, **completion_kwargs) + text = final.choices[0].message.content or "" + except Exception as e: # noqa: BLE001 + text = f"(LLM error summarizing tool results: {e})" + messages.append({"role": "assistant", "content": text}) + return text + + +# --------------------------------------------------------------------------- +# A2A executor — bridges the SDK's message/send into a turn of the agent. +# --------------------------------------------------------------------------- + + +class HRAgentExecutor(AgentExecutor): + def __init__(self) -> None: + self._agent = HRAgent() + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + user_text = context.get_user_input() + + # The a2a-sdk's DefaultCallContextBuilder stows the raw inbound + # HTTP headers under call_context.state['headers'] (lowercased). + # This is where the identity the client minted arrives — we read + # it here and thread it forward onto the tool call so cpex can + # resolve the same user/client on the outbound path. + headers: dict[str, str] = {} + if context.call_context is not None: + headers = context.call_context.state.get("headers", {}) or {} + user_token = headers.get("x-user-token", "") + authorization = headers.get("authorization", "") + + # Session id scopes cpex taint. Prefer the A2A contextId (what the + # client put in the message); fall back to an explicit header, then + # mint one so a header-less caller still gets a stable bucket. + session_id = context.context_id or headers.get("x-session-id") or uuid.uuid4().hex + + if not user_token or not authorization: + await event_queue.enqueue_event( + new_agent_text_message( + "Missing identity: the request reached the agent without " + "both X-User-Token and Authorization. Re-authenticate on " + "the client.", + context.context_id, + context.task_id, + ) + ) + return + + log.info("A2A turn (session=%s): %s", session_id, user_text) + # litellm + httpx are synchronous; run off the event loop so we + # don't block the server while the model and tools work. + reply = await asyncio.to_thread( + self._agent.run_turn, + user_text, + user_token=user_token, + client_token=authorization, + session_id=session_id, + ) + await event_queue.enqueue_event(new_agent_text_message(reply, context.context_id, context.task_id)) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + # Single-shot turns; nothing long-running to cancel. + raise NotImplementedError("cancel is not supported") + + +def build_agent_card() -> AgentCard: + return AgentCard( + name="HR Copilot", + description=( + "HR assistant that looks up employee compensation, directories, " + "and sends email via an HR MCP tool. In this demo the agent's " + "authbridge-cpex sidecar enforces CPEX/APL policy on the agent's " + "outbound tool calls — identity, Cedar authorization, SSN " + "redaction, PII scanning, RFC 8693 delegation, and cross-tool " + "session taint all fire on egress." + ), + url=AGENT_PUBLIC_URL, + version="0.1.0", + protocol_version="0.3.0", + preferred_transport="JSONRPC", + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=AgentCapabilities(streaming=False), + skills=[ + AgentSkill( + id="hr_copilot", + name="HR copilot", + description=( + "Answer HR questions by calling tools: compensation " + "lookup (with policy-gated SSN), employee directory, and " + "email. Outbound calls are governed by CPEX in the agent's " + "sidecar." + ), + tags=["demo", "hr", "cpex"], + examples=[ + "Look up compensation for EMP-001234, include the SSN.", + "Search internal repos for 'web-app'.", + ], + ) + ], + ) + + +def main() -> None: + handler = DefaultRequestHandler( + agent_executor=HRAgentExecutor(), + task_store=InMemoryTaskStore(), + ) + app = A2AStarletteApplication(agent_card=build_agent_card(), http_handler=handler).build() + log.info("HR copilot A2A agent on :%d (MCP via proxy %s → %s)", PORT, MCP_PROXY, MCP_URL) + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level=os.environ.get("LOG_LEVEL", "info").lower()) + + +if __name__ == "__main__": + main() diff --git a/authbridge/demos/hr-cpex/agent/chat.py b/authbridge/demos/hr-cpex/agent/chat.py index 7d47cc0f0..3060d6717 100755 --- a/authbridge/demos/hr-cpex/agent/chat.py +++ b/authbridge/demos/hr-cpex/agent/chat.py @@ -1,74 +1,67 @@ #!/usr/bin/env python3 -"""Interactive LLM agent in front of a CPEX-enabled gateway. - -The LLM thinks it's calling `get_compensation` / `send_email` / -`display_compensation` / `get_directory` tools directly. In reality: - - user prompt - ▼ - LLM (litellm-routed: ollama/llama3, gpt-4o-mini, claude-3-7, …) - ▼ tool_call(...) - THIS agent - ▼ POST /mcp with X-User-Token + Authorization - CPEX-enabled gateway - ▼ identity (jwt-user + jwt-client from Keycloak JWKS) - ▼ APL: require(role.hr), redact(args.ssn) when !perm.view_ssn - ▼ delegate(workday-oauth) — RFC 8693 → Keycloak - ▼ forward to upstream (workday-api token, ssn maybe redacted) - Mock HR MCP server - ◄ tool result - LLM - ◄ "Here's the data: …" - -The interesting demo moments: - - * Alice (engineer) asks for compensation → gateway returns an MCP - JSON-RPC error envelope (HTTP 200, code -32001, data.violation = - `routes.tool:get_compensation.apl.policy[0]`). The LLM sees a - tool error and apologizes politely without leaking the violation. - * Bob (HR + view_ssn) asks for SSN → gateway allows + delegates - → backend sees minted workday-api token + intact SSN - * Eve (HR, no view_ssn) asks for SSN → gateway allows + delegates - → backend sees minted token + ssn=`[REDACTED]` (the LLM presents - "[REDACTED]" as if it were the value, which is exactly the - transparent enforcement story) +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +"""Interactive A2A client for the hr-cpex agent. + +In the standalone-gateway version of this demo, chat.py *was* the agent: +it ran the LLM, called tools, and POSTed MCP directly at a CPEX gateway. +After the A2A rework the LLM and tools live in the agent container +(agent.py); this file is now a thin **A2A client** that: + + * mints a persona's user token + the hr-copilot client token (Keycloak), + * sends the user's prompt to the agent over A2A `message/send`, carrying + `X-User-Token` (persona) + `Authorization` (client) + `X-Session-Id` + (per-conversation taint scope) as HTTP headers, and + * renders the agent's reply. + +The agent re-attaches those identity headers onto its outbound MCP tool +calls, where the authbridge-cpex sidecar enforces CPEX. So the same +deny / allow / redact / taint story plays out — only now the enforcement +point is the agent's egress, not a gateway in front of the tool. + + A2A client (this file) ──message/send──► hr-cpex-agent (Service :8082) + │ X-User-Token: │ LLM + tools + │ Authorization: Bearer ▼ + │ X-Session-Id: authbridge-cpex (outbound: cpex) + ◄────────────── agent reply ───────────── hr-mcp + +Demo moments (drive these with the prompts below): + + * Bob (HR + view_ssn) → "compensation for EMP-001234, include the SSN" + → SSN passes through (cpex allow + delegate on egress). + * Eve (HR, no view_ssn) → same prompt → SSN shows as [REDACTED]. + * Alice (engineer) → compensation → denied (require(role.hr)). + * Reading compensation taints the session; a later external email is + refused (cross-tool data-flow control via cpex session taint). Usage: - pip install -r requirements.txt - - # No API keys required — default points at a local Ollama with - # llama3.1. Install Ollama (https://ollama.com) and `ollama pull - # llama3.1` first. + pip install -r requirements-client.txt python chat.py --persona bob - # Or use any LiteLLM-supported provider via env: - export OPENAI_API_KEY=... - python chat.py --persona bob --model gpt-4o-mini - - export ANTHROPIC_API_KEY=... - python chat.py --persona bob --model anthropic/claude-3-7-sonnet-20250219 - - # IBM watsonx.ai with Meta's Llama (tool-use needs 70B+): - export WATSONX_APIKEY=... - export WATSONX_URL=https://us-south.ml.cloud.ibm.com - export WATSONX_PROJECT_ID=... - python chat.py --persona bob \\ - --model watsonx/meta-llama/llama-3-3-70b-instruct - -Switch personas mid-session with `switch ` — handy for showing -deny → allow → redact in one continuous demo. Type `quit` to exit. +Switch personas mid-session with `switch ` (starts a fresh session, +so taint never leaks across the switch). `relogin` re-mints tokens for the +current persona. `quit` to exit. """ import argparse -import json +import asyncio import os import sys import uuid -from typing import Any import httpx -import litellm +from a2a.client import A2AClient +from a2a.types import ( + Message, + MessageSendParams, + Part, + Role, + SendMessageRequest, + Task, + TextPart, +) +from a2a.utils import get_message_text from rich.console import Console from rich.panel import Panel @@ -76,8 +69,11 @@ # Defaults # --------------------------------------------------------------------------- -DEFAULT_MODEL = "ollama/llama3.1" # local, no API key required -DEFAULT_GATEWAY = "http://localhost:8090/mcp" +# The agent's A2A endpoint. With `make port-forward` the agent Service +# (which fronts the authbridge-cpex sidecar's reverse proxy) is exposed on +# localhost:8082, so message/send traverses the sidecar (inbound passthrough) +# on its way to the agent. +DEFAULT_AGENT = "http://localhost:8082" DEFAULT_KEYCLOAK = "http://localhost:8081" KEYCLOAK_REALM = "cpex-demo" KEYCLOAK_CLIENT_ID = "hr-copilot" @@ -114,154 +110,16 @@ }, } -SYSTEM_PROMPT = ( - "You are an HR assistant for an HR copilot app. Help the user look up " - "employee compensation, view directories, send emails, and similar " - "tasks. Use the provided tools when needed. " - "\n\n" - "How to interpret tool results: " - "\n" - " * If the tool returns a normal result, present the data to the " - "user. If any field's value is the literal string `[REDACTED]`, " - "show it as-is in your answer — that is the gateway's transparent " - "enforcement marker that the field exists but is hidden for this " - "caller. Do NOT apologize or refuse; just include the field with " - "the value `[REDACTED]`. " - "\n" - " * If the tool returns an `error` envelope (a JSON-RPC error " - "with a `code` and `message`), the gateway denied the call. " - "Acknowledge politely without revealing the internal violation " - "code — the user may not have permission for that operation. " - "\n" - " * If the tool returns an `auth_error`, the request failed at " - "the transport layer. Ask the user to re-authenticate." -) - -TOOLS = [ - { - "type": "function", - "function": { - "name": "get_compensation", - "description": ( - "Get compensation data for an employee. Returns salary, " - "bonus, department, and optionally SSN." - ), - "parameters": { - "type": "object", - "properties": { - "employee_id": { - "type": "string", - "description": "Employee identifier (e.g., EMP-001234)", - }, - "include_ssn": { - "type": "boolean", - "description": "Whether to include SSN in the response", - "default": False, - }, - "ssn": { - "type": "string", - "description": ( - "An echo-back of the employee's SSN if the caller " - "claims to already know it — this is exactly the " - "kind of field the gateway redacts when the " - "caller lacks the necessary permission." - ), - }, - }, - "required": ["employee_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "display_compensation", - "description": ( - "Display a compensation summary for the employee (band only, " - "no salary)." - ), - "parameters": { - "type": "object", - "properties": { - "employee_id": {"type": "string"}, - }, - "required": ["employee_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "get_directory", - "description": "Get the employee directory listing.", - "parameters": { - "type": "object", - "properties": { - "department": { - "type": "string", - "description": "Optional department filter", - "default": "", - }, - }, - }, - }, - }, - { - "type": "function", - "function": { - "name": "send_email", - "description": "Send an email (simulated).", - "parameters": { - "type": "object", - "properties": { - "to": {"type": "string"}, - "subject": {"type": "string"}, - "body": {"type": "string"}, - }, - "required": ["to", "subject", "body"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "search_repos", - "description": ( - "Search the internal GitHub Enterprise for repositories. " - "Filter by name substring and/or visibility. Visibility is " - "one of `internal`, `public`, `external`." - ), - "parameters": { - "type": "object", - "properties": { - "repo_name": { - "type": "string", - "description": "Substring to filter repo names (e.g. 'web-app').", - "default": "", - }, - "visibility": { - "type": "string", - "description": ( - "Repo visibility — `internal` (default), `public`, or `external`. " - "External repos are typically off-limits for engineering." - ), - "enum": ["internal", "public", "external"], - }, - }, - "required": ["visibility"], - }, - }, - }, -] # --------------------------------------------------------------------------- -# Keycloak token minting +# Keycloak token minting (unchanged from the standalone version — the +# client still mints both tokens; only the transport to the agent changed) # --------------------------------------------------------------------------- def keycloak_token(persona: str, keycloak_host: str) -> str: - """Mint a user JWT via Keycloak password grant. Persona name is - both the username and password in the demo realm.""" + """Mint a user JWT via Keycloak password grant. Persona name is both + the username and password in the demo realm.""" info = PERSONAS[persona] token_endpoint = f"{keycloak_host}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" resp = httpx.post( @@ -282,7 +140,7 @@ def keycloak_token(persona: str, keycloak_host: str) -> str: def keycloak_client_token(keycloak_host: str) -> str: """Mint the hr-copilot client's own service-account token (the - `Authorization` header on every gateway call).""" + `Authorization` header on every agent call).""" token_endpoint = f"{keycloak_host}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" resp = httpx.post( token_endpoint, @@ -318,72 +176,84 @@ def _extract_access_token(resp: httpx.Response) -> str: # --------------------------------------------------------------------------- -# Gateway client +# A2A session # --------------------------------------------------------------------------- def new_session_id(persona: str) -> str: - """Generate a fresh CPEX session id for the chat. - - Threaded to the gateway as `X-Session-Id` (CPEX reads it off - Agent.SessionID, tier-0 of its session resolver). A fresh id per chat - launch / persona switch gives each conversation its own session-scoped - CPEX state — most visibly the taint labels: a session that has read - compensation carries the `secret` label and is then refused external - email, while a brand-new session starts clean. The persona prefix is - purely for human-readable logs; the uuid suffix is what guarantees - uniqueness.""" + """Fresh per-conversation session id, threaded to the agent as the A2A + contextId AND the X-Session-Id header. cpex (on the agent's egress) + binds it to the resolved subject to scope session state — most visibly + the taint labels: a session that has read compensation carries the + `secret` label and is then refused external email, while a brand-new + session starts clean. The persona prefix is for human-readable logs; + the uuid suffix guarantees uniqueness.""" return f"chat-{persona}-{uuid.uuid4().hex[:8]}" -class GatewayClient: - """Calls tools through the CPEX gateway. The agent sends - the client token in `Authorization` (which our jwt-client - resolver reads), the user token in `X-User-Token` (which the - jwt-user resolver reads), and a per-conversation `X-Session-Id` - (which CPEX uses to scope session state such as taint labels).""" - - def __init__(self, gateway_url: str, client_token: str, user_token: str, session_id: str): - self.gateway_url = gateway_url - self.client_token = client_token - self.user_token = user_token - self.session_id = session_id - self._request_id = 0 - - def set_user_token(self, token: str) -> None: - self.user_token = token - - def set_client_token(self, token: str) -> None: - self.client_token = token - - def set_session_id(self, session_id: str) -> None: - self.session_id = session_id - - def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> tuple[int, dict[str, Any]]: - self._request_id += 1 - payload = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - "id": self._request_id, - } - headers = { - "Authorization": f"Bearer {self.client_token}", - "Content-Type": "application/json", - "Accept": "application/json", - "X-User-Token": self.user_token, - "X-Session-Id": self.session_id, - } - resp = httpx.post(self.gateway_url, json=payload, headers=headers, timeout=30) - # Distinguish gateway-policy denies (4xx with body text) from - # downstream tool errors (200 with JSON-RPC error). Only a - # JSON-decode miss falls back to raw text; network/timeout/type - # errors propagate so they aren't masked as a {"text": ...} body. - try: - data = resp.json() - except (json.JSONDecodeError, ValueError): - data = {"text": resp.text} - return resp.status_code, data +async def send_turn( + agent_url: str, + user_text: str, + *, + user_token: str, + client_token: str, + session_id: str, +) -> str: + """Send one message/send to the agent and return the reply text. + + The identity headers ride on the HTTP request via `http_kwargs`; the + agent reads them off the inbound request and threads them onto its + cpex-governed tool calls. contextId carries the same session id inside + the A2A envelope as a belt-and-suspenders for taint scoping.""" + async with httpx.AsyncClient(timeout=60) as httpx_client: + client = A2AClient(httpx_client=httpx_client, url=agent_url) + request = SendMessageRequest( + id=uuid.uuid4().hex, + params=MessageSendParams( + message=Message( + role=Role.user, + parts=[Part(root=TextPart(text=user_text))], + message_id=uuid.uuid4().hex, + context_id=session_id, + ) + ), + ) + response = await client.send_message( + request, + http_kwargs={ + "headers": { + "X-User-Token": user_token, + "Authorization": f"Bearer {client_token}", + "X-Session-Id": session_id, + } + }, + ) + return _extract_reply(response) + + +def _extract_reply(response) -> str: + """Pull the agent's text out of a SendMessageResponse (Message or Task).""" + root = getattr(response, "root", response) + error = getattr(root, "error", None) + if error is not None: + return f"(agent error {error.code}: {error.message})" + result = getattr(root, "result", None) + if result is None: + return "(no result)" + if isinstance(result, Task): + # Prefer the status message; fall back to the first artifact. + status_msg = getattr(result.status, "message", None) + if status_msg is not None: + text = get_message_text(status_msg) + if text: + return text + for artifact in result.artifacts or []: + text = "".join(p.root.text for p in artifact.parts if isinstance(p.root, TextPart)) + if text: + return text + return "(task completed, no text)" + # Message result + return get_message_text(result) or "(empty reply)" # --------------------------------------------------------------------------- @@ -391,53 +261,7 @@ def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> tuple[int, dic # --------------------------------------------------------------------------- -def format_tool_response(status: int, data: dict[str, Any]) -> str: - """Convert the gateway's response into something compact the LLM - can read. Pull text content out of MCP `result.content[].text`. - - Three shapes the gateway can return (per MCP spec): - - * HTTP 200 + `{"result": ...}` — happy path - * HTTP 200 + `{"error": {"code": -32001, "message": ..., "data": {...}}}` - — application-level deny (policy, PDP, - PII, delegation). The LLM should treat - this as a tool-refusal. - * HTTP 401 + plain-text body — transport-level auth failure (JWT - missing / invalid / wrong audience). - Includes `WWW-Authenticate: Bearer`. - """ - if status == 401: - # Auth-level failure — transport problem, not a policy decision. - # Surface enough for the LLM to back off without retrying. - body = data.get("text") if isinstance(data, dict) else str(data) - return json.dumps({"gateway_status": 401, "auth_error": body}) - if status >= 400: - # Other HTTP errors (e.g. 502 from a Pingora upstream failure). - # CPEX puts the violation code in X-Cpex-Violation but - # we don't surface headers up here. Fall back to body. - return json.dumps({"gateway_status": status, "error": data}) - if "error" in data: - # MCP JSON-RPC error envelope — gateway-side deny (policy, PDP, PII, - # delegation). Pass the message and any violation hint through to the - # LLM so it can give the user a sensible refusal. - err = data["error"] - return json.dumps({ - "error": err.get("message", "tool error"), - "violation": (err.get("data") or {}).get("violation"), - }) - result = data.get("result", {}) - content = result.get("content", []) - text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] - combined = "".join(text_parts) - return combined or json.dumps(result) - - -def run_chat( - persona: str, - model: str, - gateway_url: str, - keycloak_host: str, -) -> None: +def run_chat(persona: str, agent_url: str, keycloak_host: str) -> None: console = Console() info = PERSONAS[persona] @@ -446,25 +270,20 @@ def run_chat( client_tok = keycloak_client_token(keycloak_host) except httpx.HTTPError as e: console.print(f"[red]Failed to mint tokens from {keycloak_host}: {e}[/red]") - console.print( - "[dim]Is Keycloak running? `docker compose up -d` from the demo " - "directory should have brought it up on :8081.[/dim]" - ) + console.print("[dim]Is Keycloak port-forwarded? `make port-forward` exposes it on :8081.[/dim]") return session_id = new_session_id(persona) - gateway = GatewayClient(gateway_url, client_tok, user_tok, session_id) console.print() console.print( Panel( f"[bold]{info['name']}[/bold] — {info['title']}\n" f"[dim]{info['description']}[/dim]\n\n" - f"[dim]Model: {model}[/dim]\n" - f"[dim]Gateway: {gateway_url}[/dim]\n" + f"[dim]Agent: {agent_url}[/dim]\n" f"[dim]Keycloak: {keycloak_host}[/dim]\n" f"[dim]Session: {session_id}[/dim]", - title="[bold]CPEX HR Demo[/bold]", + title="[bold]CPEX HR Demo — A2A client[/bold]", border_style=info["color"], ) ) @@ -474,8 +293,6 @@ def run_chat( "`relogin` to mint fresh tokens for the current persona[/dim]\n" ) - messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] - while True: try: user_input = console.input(f"[bold {info['color']}]{info['name']}:[/] ").strip() @@ -488,15 +305,15 @@ def run_chat( if user_input.lower() == "quit": console.print("[dim]bye[/dim]") return + if user_input.lower() in ("relogin", "reauth"): - # Re-mint both tokens for the current persona. The client - # token (Authorization header) is otherwise minted once at - # startup; after accessTokenLifespan it expires and every - # request fails with auth.token_expired. This is the - # demo-day escape hatch when a pause runs long. + # Re-mint both tokens for the current persona. The client token + # is otherwise minted once at startup; after accessTokenLifespan + # it expires and the agent's cpex calls fail with + # auth.token_expired. Demo-day escape hatch for long pauses. try: - gateway.set_client_token(keycloak_client_token(keycloak_host)) - gateway.set_user_token(keycloak_token(persona, keycloak_host)) + client_tok = keycloak_client_token(keycloak_host) + user_tok = keycloak_token(persona, keycloak_host) except httpx.HTTPError as e: console.print(f"[red]re-auth failed: {e}[/red]") continue @@ -516,19 +333,17 @@ def run_chat( console.print(f"[red]unknown persona '{new}'. valid: {', '.join(PERSONAS)}[/red]") continue try: - gateway.set_client_token(keycloak_client_token(keycloak_host)) - gateway.set_user_token(keycloak_token(new, keycloak_host)) + client_tok = keycloak_client_token(keycloak_host) + user_tok = keycloak_token(new, keycloak_host) except httpx.HTTPError as e: console.print(f"[red]failed to mint token for {new}: {e}[/red]") continue persona = new info = PERSONAS[persona] - # Fresh CPEX session for the new persona: switching users starts - # a clean session, so session-scoped state (taint labels, etc.) - # from the previous persona never leaks across the switch. + # Fresh session for the new persona: session-scoped state (taint + # labels, conversation history) from the previous persona never + # leaks across the switch. session_id = new_session_id(persona) - gateway.set_session_id(session_id) - messages = [{"role": "system", "content": SYSTEM_PROMPT}] console.print() console.print( Panel( @@ -541,54 +356,20 @@ def run_chat( ) continue - messages.append({"role": "user", "content": user_input}) - try: - response = litellm.completion(model=model, messages=messages, tools=TOOLS, tool_choice="auto") - except Exception as e: - console.print(f"[red]LLM error: {e}[/red]") - messages.pop() - continue - - assistant = response.choices[0].message - if not assistant.tool_calls: - text = assistant.content or "(no response)" - console.print(f"[bold]assistant:[/bold] {text}\n") - messages.append({"role": "assistant", "content": text}) - continue - - # Tool-call path. Replay through the gateway, hand the - # results back to the LLM for a final summarization. - messages.append(assistant.model_dump()) - for tc in assistant.tool_calls: - fn = tc.function - try: - args = json.loads(fn.arguments) if isinstance(fn.arguments, str) else fn.arguments - except json.JSONDecodeError: - args = {} - console.print( - f" [dim]→ {fn.name}({json.dumps(args, separators=(',', ':'))})[/dim]" + reply = asyncio.run( + send_turn( + agent_url, + user_input, + user_token=user_tok, + client_token=client_tok, + session_id=session_id, + ) ) - status, data = gateway.call_tool(fn.name, args) - tool_text = format_tool_response(status, data) - if status >= 400: - console.print(f" [dim]← [red]{status}[/red]: {tool_text}[/dim]") - else: - # Show the full tool result. Earlier versions truncated - # at 200 chars to keep the terminal scannable, but the - # demo punchline is fields like `ssn=[REDACTED]` — we - # need them visible on the wire so the audience can see - # the gateway enforcement, not just trust the LLM saw it. - console.print(f" [dim]← {tool_text}[/dim]") - messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_text}) - - try: - final = litellm.completion(model=model, messages=messages) - text = final.choices[0].message.content or "" - except Exception as e: - text = f"(LLM error summarizing tool results: {e})" - messages.append({"role": "assistant", "content": text}) - console.print(f"[bold]assistant:[/bold] {text}\n") + except httpx.HTTPError as e: + console.print(f"[red]agent call failed: {e}[/red]") + continue + console.print(f"[bold]assistant:[/bold] {reply}\n") # --------------------------------------------------------------------------- @@ -597,7 +378,7 @@ def run_chat( def main() -> int: - p = argparse.ArgumentParser(description="LLM agent in front of CPEX") + p = argparse.ArgumentParser(description="A2A client for the hr-cpex agent") p.add_argument( "--persona", default="alice", @@ -605,14 +386,9 @@ def main() -> int: help="Starting persona (switch in-session with `switch `)", ) p.add_argument( - "--model", - default=os.environ.get("DEMO_MODEL", DEFAULT_MODEL), - help=f"litellm-routed model (default: {DEFAULT_MODEL})", - ) - p.add_argument( - "--gateway", - default=os.environ.get("GATEWAY_URL", DEFAULT_GATEWAY), - help=f"CPEX endpoint (default: {DEFAULT_GATEWAY})", + "--agent", + default=os.environ.get("AGENT_URL", DEFAULT_AGENT), + help=f"Agent A2A endpoint (default: {DEFAULT_AGENT})", ) p.add_argument( "--keycloak", @@ -620,7 +396,7 @@ def main() -> int: help=f"Keycloak host (default: {DEFAULT_KEYCLOAK})", ) args = p.parse_args() - run_chat(args.persona, args.model, args.gateway, args.keycloak) + run_chat(args.persona, args.agent, args.keycloak) return 0 diff --git a/authbridge/demos/hr-cpex/agent/requirements-client.txt b/authbridge/demos/hr-cpex/agent/requirements-client.txt new file mode 100644 index 000000000..66abc3ba6 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/requirements-client.txt @@ -0,0 +1,17 @@ +# Client (chat.py) dependencies — installed on the host, NOT in any +# container image. The interactive client mints persona tokens and talks +# A2A to the agent; it does not run the LLM or call tools, so it skips +# litellm entirely and stays lightweight. +# +# Exact pins for reproducible local runs. + +# A2A client SDK. Base install (no [http-server] extra) — the client only +# sends message/send, it doesn't serve. Requires httpx>=0.28.1. +a2a-sdk==0.3.7 + +# httpx for Keycloak token minting (and the A2A client's transport). +# 0.28.1 matches a2a-sdk's floor and the server image's pin. +httpx==0.28.1 + +# Rich for pretty terminal output (boxed personas, colored replies). +rich==13.7.0 diff --git a/authbridge/demos/hr-cpex/agent/requirements.txt b/authbridge/demos/hr-cpex/agent/requirements.txt index 4c7e98a93..c1fffeee2 100644 --- a/authbridge/demos/hr-cpex/agent/requirements.txt +++ b/authbridge/demos/hr-cpex/agent/requirements.txt @@ -1,18 +1,26 @@ -# LLM provider abstraction — supports OpenAI, Anthropic, Ollama, -# WatsonX, Azure, Bedrock, and more. Lets the demo run against -# whatever the operator has access to without code changes. +# Server (agent.py) dependencies — installed into the hr-cpex-agent +# container image. The agent runs the LLM tool-calling loop and exposes +# A2A; the interactive client (chat.py) uses requirements-client.txt. # # Exact pins (== convention matches hr-mcp-server/requirements.txt) for -# reproducible builds. litellm is pinned at the lowest safe release: -# 1.82.7 / 1.82.8 had a supply-chain compromise (see CVE / GH advisory), -# so 1.83.0 is the floor. rich / httpx pinned to the lowest version that -# satisfied their prior ranges. -litellm==1.83.0 +# reproducible builds. + +# A2A server SDK. The [http-server] extra pulls starlette + sse-starlette +# (the ASGI app the agent serves). Requires httpx>=0.28.1 and +# pydantic>=2.11.3 — that floor is why httpx is bumped from the old 0.27.0. +a2a-sdk[http-server]==0.3.7 -# Rich for pretty terminal output (boxed personas, color-coded -# tool calls, etc.). -rich==13.7.0 +# ASGI server that runs the A2A Starlette app. [standard] matches the +# hr-mcp-server pin so both demo images share the uvicorn build. +uvicorn[standard]==0.32.0 + +# LLM provider abstraction — supports OpenAI, Anthropic, Ollama, WatsonX, +# Azure, Bedrock, and more, so the demo runs against whatever the operator +# has access to without code changes. litellm is pinned at the lowest safe +# release: 1.82.7 / 1.82.8 had a supply-chain compromise, so 1.83.0 is the +# floor. +litellm==1.83.0 -# httpx for the gateway POST. Already pulled transitively by litellm -# but listed explicitly so the dep is visible. -httpx==0.27.0 +# httpx for the MCP tool call (routed through the sidecar forward proxy). +# Bumped to 0.28.1 to satisfy a2a-sdk's httpx>=0.28.1 floor. +httpx==0.28.1 diff --git a/authbridge/demos/hr-cpex/k8s/30-agent.yaml b/authbridge/demos/hr-cpex/k8s/30-agent.yaml new file mode 100644 index 000000000..71757288e --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/30-agent.yaml @@ -0,0 +1,218 @@ +# hr-cpex agent + authbridge-cpex sidecar — the A2A-rework deployment. +# +# This replaces the old standalone-gateway 30-authbridge-cpex.yaml. Instead +# of CPEX fronting hr-mcp on the INBOUND path, the agent now runs as a +# container and authbridge-cpex is its OUTBOUND sidecar: CPEX fires on the +# agent's tool calls (mcp-parser → cpex outbound), making it a transparent +# egress guardrail on the agent. Mirrors the ibac demo's shape, but stays +# self-contained (explicit sidecar in the pod spec, no operator injection). +# +# Traffic: +# host A2A client ──► Service hr-cpex-agent :8080 +# └─► sidecar reverse proxy :8000 (inbound: passthrough) +# └─► agent :8001 (A2A message/send) +# agent tool call ──► sidecar forward proxy :8081 (outbound: mcp-parser → cpex) +# └─► hr-mcp :9100 +# +# The agent's inference (litellm → Ollama) does NOT go through the sidecar: +# the agent uses an explicit per-client proxy for the MCP call only and we +# deliberately do NOT set HTTP_PROXY on the agent container. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: authbridge-config + namespace: cpex-demo +data: + authbridge.yaml: | + mode: proxy-sidecar + listener: + # Reverse proxy fronts the agent (inbound passthrough — see pipeline). + reverse_proxy_addr: ":8000" + reverse_proxy_backend: "http://localhost:8001" + # Forward proxy is where the agent's MCP tool calls egress; cpex runs + # on this outbound path. + forward_proxy_addr: ":8081" + # Session API and stats bind to localhost only — both expose raw + # user content / internal state and have no auth. Reach them via + # `kubectl port-forward` (works against the pod regardless of + # Service ports), never cluster-wide. + session_api_addr: "127.0.0.1:9094" + stats: + stats_address: "127.0.0.1:9093" + session: + # The taint scenarios (08/09) depend on the session store; keep it on. + enabled: true + ttl: 30m + pipeline: + # Inbound is pure passthrough: the reverse proxy must forward the A2A + # client's X-User-Token + Authorization + X-Session-Id headers to the + # agent unmodified, so the agent can re-attach them on egress. cpex + # does NOT run here — enforcement moved to the outbound path. + inbound: + plugins: [] + outbound: + plugins: + # mcp-parser populates pctx.Extensions.MCP from the agent's + # JSON-RPC tool call; cpex reads from there for CMF dispatch. + - name: mcp-parser + # cpex handles identity, policy, delegation, redaction, PII, and + # session taint entirely via its own sub-plugins. Identity + # resolvers (jwt-user ← X-User-Token, jwt-client ← Authorization) + # MUST run here so the resolved subject is present — the cpex + # session store binds taint to that subject (Finding 2). The + # CPEX-side YAML lives in the cpex-policy ConfigMap, mounted at + # /etc/cpex/cpex.yaml. + - name: cpex + config: + hooks: + on_request: + - cmf.tool_pre_invoke + on_response: + - cmf.tool_post_invoke + config_file: /etc/cpex/cpex.yaml + fail_open: false + # Bypass hosts default covers keycloak/SPIRE/observability so + # those egress calls don't pay the FFI cost. + +--- +apiVersion: v1 +kind: Service +metadata: + name: hr-cpex-agent + namespace: cpex-demo + labels: + app.kubernetes.io/name: hr-cpex-agent + app.kubernetes.io/part-of: cpex-demo +spec: + selector: + app.kubernetes.io/name: hr-cpex-agent + # Only the sidecar reverse proxy is exposed cluster-wide, so the A2A + # client talks THROUGH the sidecar (honest sidecar shape; inbound is + # passthrough). The forward proxy (8081) and session API (9094) bind + # locally and are reached via `kubectl port-forward` against the pod. + ports: + - name: a2a + port: 8080 + targetPort: 8000 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hr-cpex-agent + namespace: cpex-demo + labels: + app.kubernetes.io/name: hr-cpex-agent + app.kubernetes.io/part-of: cpex-demo +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: hr-cpex-agent + template: + metadata: + labels: + app.kubernetes.io/name: hr-cpex-agent + app.kubernetes.io/part-of: cpex-demo + spec: + containers: + # --- The agent (A2A server + litellm tool loop) --- + - name: agent + image: hr-cpex-agent:dev + imagePullPolicy: Never + env: + - name: PORT + value: "8001" + # The agent POSTs MCP here; httpx routes it through MCP_PROXY. + - name: MCP_URL + value: "http://hr-mcp.cpex-demo:9100/mcp" + # Sidecar forward proxy — used by the MCP client ONLY. There is + # deliberately NO HTTP_PROXY env: a global proxy would drag the + # litellm inference call through cpex too (the Ollama footgun). + - name: MCP_PROXY + value: "http://localhost:8081" + # Inference endpoint. Native Ollama base (litellm appends + # /api/...). The Makefile overrides MODEL + LLM_API_BASE via + # `kubectl set env` on apply, so set them there (or `make deploy + # LLM_API_BASE=...`), not here. Defaults below are the + # Docker-Desktop / kind-with-host-mappings case; on Rancher + # Desktop the host is reached at http://192.168.5.2:11434 + # (lima host-gateway) and Ollama must bind 0.0.0.0. + - name: MODEL + value: "ollama/llama3.2:3b" + - name: LLM_API_BASE + value: "http://host.docker.internal:11434" + - name: AGENT_PUBLIC_URL + value: "http://hr-cpex-agent.cpex-demo:8080/" + - name: LOG_LEVEL + value: "info" + ports: + - name: agent + containerPort: 8001 + # Gate pod readiness on the agent's A2A server actually listening + # (litellm import delays uvicorn ~10s). Without this, the pod goes + # Ready as soon as the sidecar is, and a client hitting the reverse + # proxy mid-startup gets a 503 ("all connection attempts failed"). + readinessProbe: + tcpSocket: + port: 8001 + initialDelaySeconds: 3 + periodSeconds: 3 + resources: + requests: + cpu: "50m" + memory: "128Mi" + limits: + memory: "512Mi" + + # --- authbridge-cpex sidecar (CPEX on the agent's egress) --- + - name: authbridge-cpex + image: authbridge-cpex:dev + imagePullPolicy: Never + args: + - "--config" + - "/etc/authbridge/authbridge.yaml" + env: + - name: LOG_LEVEL + value: "info" + ports: + - name: reverse-proxy + containerPort: 8000 + - name: forward-proxy + containerPort: 8081 + - name: session-api + containerPort: 9094 + - name: stats + containerPort: 9093 + - name: health + containerPort: 9091 + volumeMounts: + - name: authbridge-config + mountPath: /etc/authbridge + readOnly: true + - name: cpex-policy + mountPath: /etc/cpex + readOnly: true + readinessProbe: + # /readyz gates on plugin readiness (ANDs Ready() across + # plugins); /healthz is always 200 and only fits liveness. + httpGet: + path: /readyz + port: 9091 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "512Mi" + volumes: + - name: authbridge-config + configMap: + name: authbridge-config + - name: cpex-policy + configMap: + name: cpex-policy diff --git a/authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml b/authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml deleted file mode 100644 index 87d109faa..000000000 --- a/authbridge/demos/hr-cpex/k8s/30-authbridge-cpex.yaml +++ /dev/null @@ -1,148 +0,0 @@ -# authbridge-cpex gateway — sidecar-shape Deployment. -# -# The authbridge.yaml ConfigMap below carries the AuthBridge config -# (mode, listeners, pipeline). The cpex-policy ConfigMap (created by -# the Makefile from k8s/cpex-policy.yaml) carries the CPEX runtime -# YAML — what CPEX itself parses. Both mount as files; the AuthBridge -# config references cpex-policy via config_file. -# -# Image: authbridge-cpex:dev — built locally and loaded into kind via -# `kind load docker-image authbridge-cpex:dev --name kagenti`. - -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: cpex-demo -data: - authbridge.yaml: | - mode: proxy-sidecar - listener: - # Reverse proxy fronts hr-mcp inside the cluster. - reverse_proxy_addr: ":8080" - reverse_proxy_backend: "http://hr-mcp.cpex-demo:9100" - forward_proxy_addr: ":8081" - # Session API and stats bind to localhost only — both expose raw - # user content / internal state and have no auth. Reach them via - # `kubectl port-forward` (works against the pod regardless of - # Service ports), never cluster-wide. - session_api_addr: "127.0.0.1:9094" - stats: - stats_address: "127.0.0.1:9093" - session: - enabled: true - ttl: 30m - pipeline: - inbound: - plugins: - # mcp-parser populates pctx.Extensions.MCP from the - # JSON-RPC body; cpex reads from there for CMF dispatch. - - name: mcp-parser - # cpex handles identity, policy, delegation entirely via - # its own sub-plugins (apl.identity.jwt, apl.pdp.cedar, - # apl-delegator-oauth, validator/pii-scan, audit/logger). - # The CPEX-side YAML lives in cpex-policy ConfigMap, - # mounted at /etc/cpex/cpex.yaml. - - name: cpex - config: - hooks: - on_request: - - cmf.tool_pre_invoke - on_response: - - cmf.tool_post_invoke - config_file: /etc/cpex/cpex.yaml - fail_open: false - # Bypass paths get stripped before CPEX evaluation so - # health probes and discovery never pay the FFI cost. - # Bypass hosts default covers keycloak/SPIRE/observability. - outbound: - plugins: [] - ---- -apiVersion: v1 -kind: Service -metadata: - name: authbridge-cpex - namespace: cpex-demo -spec: - selector: - app.kubernetes.io/name: authbridge-cpex - ports: - # Only the reverse proxy is exposed cluster-wide. Session API (9094) - # and stats (9093) bind to localhost in the config above and are - # reached via `kubectl port-forward` against the pod, so they are - # deliberately NOT listed here. - - name: reverse-proxy - port: 8080 - targetPort: 8080 - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authbridge-cpex - namespace: cpex-demo - labels: - app.kubernetes.io/name: authbridge-cpex - app.kubernetes.io/part-of: cpex-demo -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: authbridge-cpex - template: - metadata: - labels: - app.kubernetes.io/name: authbridge-cpex - app.kubernetes.io/part-of: cpex-demo - spec: - containers: - - name: authbridge-cpex - image: authbridge-cpex:dev - imagePullPolicy: Never - args: - - "--config" - - "/etc/authbridge/authbridge.yaml" - env: - - name: LOG_LEVEL - value: "info" - ports: - - name: reverse-proxy - containerPort: 8080 - - name: forward-proxy - containerPort: 8081 - - name: session-api - containerPort: 9094 - - name: stats - containerPort: 9093 - - name: health - containerPort: 9091 - volumeMounts: - - name: authbridge-config - mountPath: /etc/authbridge - readOnly: true - - name: cpex-policy - mountPath: /etc/cpex - readOnly: true - readinessProbe: - # /readyz gates on plugin readiness (ANDs Ready() across - # plugins); /healthz is always 200 and only fits liveness. - httpGet: - path: /readyz - port: 9091 - initialDelaySeconds: 5 - periodSeconds: 5 - resources: - requests: - cpu: "100m" - memory: "256Mi" - limits: - cpu: "1000m" - memory: "512Mi" - volumes: - - name: authbridge-config - configMap: - name: authbridge-config - - name: cpex-policy - configMap: - name: cpex-policy diff --git a/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh index 4ecce6adb..e91161f06 100755 --- a/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh +++ b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh @@ -26,7 +26,7 @@ note " permissions claim includes repo:read:internal" ALICE=$(mint alice) CLIENT=$(mint hr-copilot) -curl -s -X POST "$GATEWAY/mcp" \ +curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CLIENT" \ -H "X-User-Token: $ALICE" \ diff --git a/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh index 5e17bec7b..dae00e422 100755 --- a/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh @@ -25,7 +25,7 @@ note "Expected upstream: no inbound request (gateway short-circuits at PDP)" ALICE=$(mint alice) CLIENT=$(mint hr-copilot) -curl -s -X POST "$GATEWAY/mcp" \ +curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CLIENT" \ -H "X-User-Token: $ALICE" \ diff --git a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh index 050c49239..1c5ae36fb 100755 --- a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh @@ -26,7 +26,7 @@ note "Expected: Cedar never runs; IdP never called" BOB=$(mint bob) CLIENT=$(mint hr-copilot) -curl -s -X POST "$GATEWAY/mcp" \ +curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CLIENT" \ -H "X-User-Token: $BOB" \ diff --git a/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh b/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh index 3c74fabea..025e3c00d 100755 --- a/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh @@ -32,7 +32,7 @@ note "Expected upstream: no inbound request (gateway plugin denied)" BOB=$(mint bob) CLIENT=$(mint hr-copilot) -curl -s -X POST "$GATEWAY/mcp" \ +curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CLIENT" \ -H "X-User-Token: $BOB" \ diff --git a/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh b/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh index b45b13346..140127abf 100755 --- a/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh @@ -20,7 +20,7 @@ # S2 new session, get_compensation → 200 (taints session "secret") # S3 SAME session as S2, clean body → 403 cpex.session_tainted_secret # -# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/authbridge-cpex +# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex set -euo pipefail source "$(dirname "$0")/_lib.sh" diff --git a/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh b/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh index 259b471ef..6b7f72552 100755 --- a/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh +++ b/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh @@ -30,7 +30,7 @@ # is denied (cpex.session_tainted_secret). Same id, different subject = # different outcome. Pre-fix, S3 would have returned 403. # -# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/authbridge-cpex +# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex set -euo pipefail source "$(dirname "$0")/_lib.sh" diff --git a/authbridge/demos/hr-cpex/scenarios/_lib.sh b/authbridge/demos/hr-cpex/scenarios/_lib.sh index 9117b45f6..d7f42ee15 100755 --- a/authbridge/demos/hr-cpex/scenarios/_lib.sh +++ b/authbridge/demos/hr-cpex/scenarios/_lib.sh @@ -2,7 +2,19 @@ # # source "$(dirname "$0")/_lib.sh" -GATEWAY="${GATEWAY:-http://localhost:8090}" +# Post-A2A-rework: CPEX now enforces on the agent's OUTBOUND path, so the +# deterministic curl scenarios drive the tool through the sidecar's FORWARD +# proxy (not a reverse-proxy gateway in front of hr-mcp). curl uses the +# forward proxy with the absolute in-cluster MCP URL — the proxy resolves +# the host cluster-side and runs the outbound mcp-parser → cpex pipeline, +# exactly as the agent's own tool calls do. +# +# PROXY — sidecar forward proxy on the host (port-forwarded by +# `make port-forward` as 8083 -> pod 8081; 8081 is taken +# by Keycloak on the host) +# MCP_TARGET — absolute URL the proxy forwards to (cluster DNS) +PROXY="${PROXY:-http://localhost:8083}" +MCP_TARGET="${MCP_TARGET:-http://hr-mcp.cpex-demo:9100/mcp}" DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" mint() { @@ -41,7 +53,7 @@ _post_tool() { # `${extra[@]+"${extra[@]}"}` expands to nothing when the array is empty # without tripping `set -u` on bash 3.2 (macOS), where a bare # `"${extra[@]}"` on an empty array errors "unbound variable". - curl -isS --max-time 10 -X POST "$GATEWAY/mcp" \ + curl -isS --max-time 10 -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $client_token" \ -H "X-User-Token: $user_token" \ From cd2e38b751c0d0c626f7e51ec973be4a4324ef67 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 16:18:06 -0400 Subject: [PATCH 04/18] fix(cpex): address CI failures and automated review findings CI: - Bump proxy/envoy/lite go.mod to go 1.25.4 to match authlib (which the CPEX dependency raised), so `setup-go` installs a toolchain that can build the shared library under GOTOOLCHAIN=local. - Add `# shellcheck shell=bash` to hr-cpex scenarios/_lib.sh (SC2148). - Bump litellm 1.83.0 -> 1.83.10 in the hr-cpex agent (SQLi/SSTI/RCE/ sandbox-escape cluster; GHSA-r75f-5x8p-qvmc, -xqmj-j6mv-4862, -v4p8-mg3p-g94g, -wxxx-gvqv-xp7p). CPEX plugin (correctness/security): - cmf_a2a: align write-back target selection with the read-side non-empty-text contract so empty text parts don't trip false count-drift or rewrite the wrong part. - cmf_inference: fail closed on multi-choice responses instead of stamping one redacted completion over distinct choices. - plugin: stop leaking raw internal CPEX/FFI error text to clients on deny paths; log detail, return a generic message. - Add regression tests for both fail-closed/alignment behaviors. hr-cpex demo: - Pin the cpex runtime base image (debian:trixie-slim) by digest. - Bump fastapi -> 0.136.3 to pull a patched starlette (transitive multipart/Range/Host-header advisories). - Sanitize user-controlled values in hr-mcp-server logs (CWE-117) and return a generic tool-error message instead of exception detail. - Wrap the agent's MCP POST so transport errors return a structured tool error rather than aborting the turn. - Add baseline container securityContext (privesc off, drop ALL caps, RuntimeDefault seccomp) to the demo pods; runAsNonRoot for the cpex sidecar. - Make the demo Makefile NAMESPACE fixed (manifests hardcode cpex-demo). Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/cmf_a2a.go | 12 +++++-- .../authlib/plugins/cpex/cmf_a2a_test.go | 24 +++++++++++++ .../authlib/plugins/cpex/cmf_inference.go | 36 ++++++++++++++----- .../plugins/cpex/cmf_inference_test.go | 20 +++++++++++ authbridge/authlib/plugins/cpex/plugin.go | 12 +++++-- authbridge/cmd/authbridge-cpex/Dockerfile | 2 +- authbridge/cmd/authbridge-envoy/go.mod | 2 +- authbridge/cmd/authbridge-lite/go.mod | 2 +- authbridge/cmd/authbridge-proxy/go.mod | 2 +- authbridge/demos/hr-cpex/Makefile | 7 +++- authbridge/demos/hr-cpex/agent/agent.py | 10 ++++-- .../demos/hr-cpex/agent/requirements.txt | 8 +++-- .../hr-cpex/hr-mcp-server/requirements.txt | 7 +++- .../demos/hr-cpex/hr-mcp-server/server.py | 27 ++++++++++---- authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml | 10 ++++++ authbridge/demos/hr-cpex/k8s/30-agent.yaml | 22 ++++++++++++ authbridge/demos/hr-cpex/scenarios/_lib.sh | 1 + 17 files changed, 172 insertions(+), 32 deletions(-) diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a.go b/authbridge/authlib/plugins/cpex/cmf_a2a.go index 833fe2359..17f7b77c2 100644 --- a/authbridge/authlib/plugins/cpex/cmf_a2a.go +++ b/authbridge/authlib/plugins/cpex/cmf_a2a.go @@ -102,8 +102,14 @@ func applyA2ARequestBodyMod(pctx *pipeline.Context, newTexts []string) (mutated if !ok { continue } + // Only non-empty text parts participate, matching the read side + // (a2aToCMFParts / a2aResponseParts both drop empty text). Counting + // empty text-kind parts here would drift against newTexts and + // trip a false count-mismatch error. if kind, _ := po["kind"].(string); kind == "text" { - targets = append(targets, po) + if t, ok := po["text"].(string); ok && t != "" { + targets = append(targets, po) + } } } @@ -179,7 +185,9 @@ func applyA2AResponseBodyMod(pctx *pipeline.Context, newArtifact string) (mutate if kind, _ := po["kind"].(string); kind != "text" { continue } - if _, ok := po["text"].(string); !ok { + // Skip empty text parts so we rewrite the same part the read + // side surfaced (a2aResponseParts emits only non-empty text). + if t, ok := po["text"].(string); !ok || t == "" { continue } po["text"] = newArtifact diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go index 44548ad4f..e4953df4b 100644 --- a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go +++ b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go @@ -83,6 +83,30 @@ func TestA2ARequestBodyMod_CountDriftFailsClosed(t *testing.T) { } } +func TestA2ARequestBodyMod_EmptyTextPartNotCounted(t *testing.T) { + // An empty text-kind part is dropped by the read side (a2aToCMFParts), + // so the write side must not count it either — otherwise it drifts + // against newTexts and trips a false count-mismatch error. + pctx := &pipeline.Context{ + Body: []byte(`{"params":{"message":{"parts":[{"kind":"text","text":"ssn 123-45-6789"},{"kind":"text","text":""}]}}}`), + } + mutated, err := applyA2ARequestBodyMod(pctx, []string{"ssn [REDACTED]"}) + if err != nil || !mutated { + t.Fatalf("empty text part should not cause drift; mutated=%v err=%v", mutated, err) + } + var decoded map[string]any + if err := json.Unmarshal(pctx.Body, &decoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + parts := decoded["params"].(map[string]any)["message"].(map[string]any)["parts"].([]any) + if got := parts[0].(map[string]any)["text"]; got != "ssn [REDACTED]" { + t.Fatalf("non-empty text part not redacted: %v", got) + } + if got := parts[1].(map[string]any)["text"]; got != "" { + t.Fatalf("empty text part must stay untouched, got %v", got) + } +} + // --- applyA2AResponseBodyMod --- func TestA2AResponseBodyMod_RewritesArtifact(t *testing.T) { diff --git a/authbridge/authlib/plugins/cpex/cmf_inference.go b/authbridge/authlib/plugins/cpex/cmf_inference.go index 7d3d45dc2..f06c46af8 100644 --- a/authbridge/authlib/plugins/cpex/cmf_inference.go +++ b/authbridge/authlib/plugins/cpex/cmf_inference.go @@ -151,11 +151,16 @@ func applyInferenceRequestBodyMod(pctx *pipeline.Context, newContents []string) // assistant completion text with newCompletion (the single redacted text // part CPEX returned on the response phase). // -// It rewrites `choices[].message.content` for every choice that currently -// carries a string content. Streaming responses (SSE: a sequence of -// `data:` frames, not a single JSON object) are not rewritable here — the -// body won't parse as one JSON object, so we return an error and let the -// caller fail closed rather than forward an unredacted stream. +// It rewrites the single string-content choice's `message.content`. +// Because the write side receives only one redacted completion while the +// read side (inferenceResponseParts) emits one text part per choice, a +// response carrying more than one string-content choice (n>1) is an +// ambiguous single-value rewrite — we fail closed rather than overwrite +// the other choices' distinct completions with one value. Streaming +// responses (SSE: a sequence of `data:` frames, not a single JSON object) +// are not rewritable here — the body won't parse as one JSON object, so we +// return an error and let the caller fail closed rather than forward an +// unredacted stream. // // Returns mutated=false (no error) when there's nothing to change: // newCompletion empty, no choices, or no string content to replace. @@ -174,7 +179,12 @@ func applyInferenceResponseBodyMod(pctx *pipeline.Context, newCompletion string) if !ok { return false, nil } - changed := false + + // Collect the choices carrying string content, matching what the read + // side surfaced. We only hold one redacted completion, so >1 such + // choice is ambiguous: fail closed instead of stamping the same value + // over distinct completions. + targets := make([]map[string]any, 0, len(choices)) for _, ch := range choices { choice, ok := ch.(map[string]any) if !ok { @@ -185,13 +195,21 @@ func applyInferenceResponseBodyMod(pctx *pipeline.Context, newCompletion string) continue } if c, ok := msg["content"].(string); ok && c != "" { - msg["content"] = newCompletion - changed = true + targets = append(targets, msg) } } - if !changed { + if len(targets) == 0 { + return false, nil + } + if len(targets) > 1 { + return false, fmt.Errorf( + "inference response has %d string-content choices; single-value redaction rewrite is ambiguous", + len(targets)) + } + if targets[0]["content"] == newCompletion { return false, nil } + targets[0]["content"] = newCompletion newBody, err := json.Marshal(envelope) if err != nil { diff --git a/authbridge/authlib/plugins/cpex/cmf_inference_test.go b/authbridge/authlib/plugins/cpex/cmf_inference_test.go index 3e06c605c..7e48cd822 100644 --- a/authbridge/authlib/plugins/cpex/cmf_inference_test.go +++ b/authbridge/authlib/plugins/cpex/cmf_inference_test.go @@ -142,6 +142,26 @@ func TestInferenceResponseBodyMod_RewritesChoiceContent(t *testing.T) { } } +func TestInferenceResponseBodyMod_MultiChoiceFailsClosed(t *testing.T) { + // n>1: two choices carry string content, but only one redacted + // completion is available. Stamping it over both would collapse + // distinct completions, so the rewrite must fail closed. + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"choices":[{"message":{"role":"assistant","content":"first 123-45-6789"}},{"message":{"role":"assistant","content":"second answer"}}]}`), + } + original := append([]byte(nil), pctx.ResponseBody...) + mutated, err := applyInferenceResponseBodyMod(pctx, "first [REDACTED]") + if err == nil { + t.Fatal("multi-choice response rewrite should fail closed with an error") + } + if mutated { + t.Fatal("must not mutate a multi-choice response") + } + if string(pctx.ResponseBody) != string(original) { + t.Fatalf("response body must be left untouched on fail-closed, got %s", pctx.ResponseBody) + } +} + func TestInferenceResponseBodyMod_StreamingFailsClosed(t *testing.T) { pctx := &pipeline.Context{ ResponseBody: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n"), diff --git a/authbridge/authlib/plugins/cpex/plugin.go b/authbridge/authlib/plugins/cpex/plugin.go index e9e84aaeb..fc62d96e0 100644 --- a/authbridge/authlib/plugins/cpex/plugin.go +++ b/authbridge/authlib/plugins/cpex/plugin.go @@ -340,11 +340,14 @@ func (p *CPEX) handleInvokeError(pctx *pipeline.Context, hook string, err error) // A CPEX-internal failure (FFI error, runtime panic) is an upstream // fault, not a policy decision, so reject with 502. Status is set // explicitly for the same reason as the deny path: cpex.error is not - // in the listener's static codeToStatus table. + // in the listener's static codeToStatus table. The detailed error + // (which may name FFI symbols, backends, or config paths) is logged + // for operators; the client only sees a generic message. + slog.Error("cpex: invoke failed", "hook", hook, "error", err) return denyWithStatus(pctx, http.StatusBadGateway, fmt.Sprintf("cpex error on %s", hook), "cpex.error", - err.Error(), + "cpex invocation failed", ) } @@ -452,10 +455,13 @@ func (p *CPEX) handleDecisionError(pctx *pipeline.Context, err error) pipeline.A pctx.Observe(fmt.Sprintf("cpex error (fail_open): %v", err)) return pipeline.Action{Type: pipeline.Continue} } + // Log the detailed error for operators; return a generic client message + // so internal CPEX/runtime details don't leak in the deny response. + slog.Error("cpex: decision handling failed", "error", err) return denyWithStatus(pctx, http.StatusBadGateway, fmt.Sprintf("cpex error: %v", err), "cpex.error", - err.Error(), + "cpex decision handling failed", ) } diff --git a/authbridge/cmd/authbridge-cpex/Dockerfile b/authbridge/cmd/authbridge-cpex/Dockerfile index d010f6bfb..f94252bf4 100644 --- a/authbridge/cmd/authbridge-cpex/Dockerfile +++ b/authbridge/cmd/authbridge-cpex/Dockerfile @@ -95,7 +95,7 @@ RUN cd cmd/authbridge-cpex && \ # but fails at runtime with "GLIBC_2.39 not found". When we eventually # move to a statically-linked CGO build (or the musl asset variant), # we can downgrade this back to the smaller base. -FROM debian:trixie-slim +FROM debian:trixie-slim@sha256:b6e2a152f22a40ff69d92cb397223c906017e1391a73c952b588e51af8883bf8 RUN apt-get update && apt-get install -y --no-install-recommends \ bash ca-certificates && \ diff --git a/authbridge/cmd/authbridge-envoy/go.mod b/authbridge/cmd/authbridge-envoy/go.mod index 18a5aa0cb..7927fcf80 100644 --- a/authbridge/cmd/authbridge-envoy/go.mod +++ b/authbridge/cmd/authbridge-envoy/go.mod @@ -1,6 +1,6 @@ module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-envoy -go 1.25.0 +go 1.25.4 replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../../authlib diff --git a/authbridge/cmd/authbridge-lite/go.mod b/authbridge/cmd/authbridge-lite/go.mod index 28ef510c6..269700ac2 100644 --- a/authbridge/cmd/authbridge-lite/go.mod +++ b/authbridge/cmd/authbridge-lite/go.mod @@ -1,6 +1,6 @@ module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-lite -go 1.25.0 +go 1.25.4 replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../../authlib diff --git a/authbridge/cmd/authbridge-proxy/go.mod b/authbridge/cmd/authbridge-proxy/go.mod index 5af43c6ea..5642850f7 100644 --- a/authbridge/cmd/authbridge-proxy/go.mod +++ b/authbridge/cmd/authbridge-proxy/go.mod @@ -1,6 +1,6 @@ module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-proxy -go 1.25.0 +go 1.25.4 require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101000000-000000000000 diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile index cfdfee785..e991c2a84 100644 --- a/authbridge/demos/hr-cpex/Makefile +++ b/authbridge/demos/hr-cpex/Makefile @@ -19,7 +19,12 @@ # (hr-mcp-server/, k8s/, agent/). KIND_CLUSTER ?= kagenti -NAMESPACE ?= cpex-demo +# Fixed, not overridable: the k8s/*.yaml manifests hardcode the +# `cpex-demo` namespace (00-namespace.yaml plus metadata.namespace on +# every object), so an override here would create the ConfigMaps in one +# namespace while the Deployments landed in `cpex-demo` — and the later +# `set env` / `rollout status` targets would miss them. +NAMESPACE := cpex-demo # Image tags — local images, loaded into kind, never pushed to a # registry. imagePullPolicy: Never in the Deployment manifests. diff --git a/authbridge/demos/hr-cpex/agent/agent.py b/authbridge/demos/hr-cpex/agent/agent.py index 35ec18c99..b541e4973 100644 --- a/authbridge/demos/hr-cpex/agent/agent.py +++ b/authbridge/demos/hr-cpex/agent/agent.py @@ -296,8 +296,14 @@ def call_tool( "X-Session-Id": session_id, } # Explicit per-client proxy — ONLY this flow goes through cpex. - with httpx.Client(proxy=MCP_PROXY, timeout=30) as client: - resp = client.post(MCP_URL, json=payload, headers=headers) + try: + with httpx.Client(proxy=MCP_PROXY, timeout=30) as client: + resp = client.post(MCP_URL, json=payload, headers=headers) + except httpx.HTTPError as e: + # Transport failure (timeout, connect refused, reset) — return a + # structured tool error so the tool-calling loop can report it + # rather than letting the exception abort the whole A2A turn. + return 502, {"error": f"MCP transport error: {e}"} try: data = resp.json() except (json.JSONDecodeError, ValueError): diff --git a/authbridge/demos/hr-cpex/agent/requirements.txt b/authbridge/demos/hr-cpex/agent/requirements.txt index c1fffeee2..bc0eca7cd 100644 --- a/authbridge/demos/hr-cpex/agent/requirements.txt +++ b/authbridge/demos/hr-cpex/agent/requirements.txt @@ -17,9 +17,11 @@ uvicorn[standard]==0.32.0 # LLM provider abstraction — supports OpenAI, Anthropic, Ollama, WatsonX, # Azure, Bedrock, and more, so the demo runs against whatever the operator # has access to without code changes. litellm is pinned at the lowest safe -# release: 1.82.7 / 1.82.8 had a supply-chain compromise, so 1.83.0 is the -# floor. -litellm==1.83.0 +# release: 1.82.7 / 1.82.8 had a supply-chain compromise (1.83.0 floor) and +# 1.83.0–1.83.9 carry a SQL-injection / SSTI / RCE / sandbox-escape cluster +# (GHSA-r75f-5x8p-qvmc, -xqmj-j6mv-4862, -v4p8-mg3p-g94g, -wxxx-gvqv-xp7p), +# all patched by 1.83.10. +litellm==1.83.10 # httpx for the MCP tool call (routed through the sidecar forward proxy). # Bumped to 0.28.1 to satisfy a2a-sdk's httpx>=0.28.1 floor. diff --git a/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt b/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt index 0ea2c631a..824c5f73a 100644 --- a/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt +++ b/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt @@ -1,2 +1,7 @@ -fastapi==0.115.4 +# fastapi 0.136.3 requires starlette>=0.46.0, which resolves to a +# release past the HIGH-severity starlette advisories that 0.41.x carried +# (multipart large-file DoS 0.47.2, Range/FileResponse DoS 0.49.1, +# Host header validation 1.0.1). Bumping uvicorn alone would not move +# the transitive starlette pin. +fastapi==0.136.3 uvicorn[standard]==0.32.0 diff --git a/authbridge/demos/hr-cpex/hr-mcp-server/server.py b/authbridge/demos/hr-cpex/hr-mcp-server/server.py index f8a516f28..e33f1dfd6 100644 --- a/authbridge/demos/hr-cpex/hr-mcp-server/server.py +++ b/authbridge/demos/hr-cpex/hr-mcp-server/server.py @@ -200,6 +200,14 @@ def _redact_token(value: str) -> str: return value +def _log_safe(value: Any) -> str: + """Neutralize a user-controlled value for single-line logging: coerce to + str and replace CR/LF and other control characters with spaces so a + crafted request field can't forge or split log entries (CWE-117).""" + s = value if isinstance(value, str) else str(value) + return "".join(ch if ch.isprintable() else " " for ch in s) + + @app.post("/mcp") async def mcp_endpoint(request: Request) -> JSONResponse: body_bytes = await request.body() @@ -217,10 +225,13 @@ async def mcp_endpoint(request: Request) -> JSONResponse: logger.info(" %-25s = %s", name, _redact_token(v)) try: rpc = json.loads(body_bytes) - logger.info(" body.method = %s", rpc.get("method")) + logger.info(" body.method = %s", _log_safe(rpc.get("method"))) params = rpc.get("params", {}) - logger.info(" body.params.name = %s", params.get("name")) - logger.info(" body.params.arguments = %s", json.dumps(params.get("arguments", {}))) + logger.info(" body.params.name = %s", _log_safe(params.get("name"))) + logger.info( + " body.params.arguments = %s", + _log_safe(json.dumps(params.get("arguments", {}))), + ) except Exception as e: logger.warning("body is not JSON-RPC: %s", e) return JSONResponse( @@ -252,19 +263,21 @@ async def mcp_endpoint(request: Request) -> JSONResponse: try: out = impl(args) - except Exception as e: - logger.exception("tool '%s' failed", tool_name) + except Exception: + # Full detail (including the stack trace) goes to the server log; + # the client gets a generic message so internal state isn't exposed. + logger.exception("tool '%s' failed", _log_safe(tool_name)) return JSONResponse( { "jsonrpc": "2.0", - "error": {"code": -32000, "message": str(e)}, + "error": {"code": -32000, "message": "internal tool error"}, "id": rpc_id, }, status_code=500, ) logger.info("OUTBOUND RESPONSE") - logger.info(" tool = %s", tool_name) + logger.info(" tool = %s", _log_safe(tool_name)) logger.info(" payload = %s", json.dumps(out)[:200]) return JSONResponse( { diff --git a/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml b/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml index 8e3ccef62..e2fa99f17 100644 --- a/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml +++ b/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml @@ -45,6 +45,16 @@ spec: - name: hr-mcp image: hr-mcp:dev imagePullPolicy: Never + # Baseline container hardening — drop OS privilege the demo + # never needs. (Non-root execution would require the image to + # add a USER; left out so the demo image stays minimal.) + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault ports: - name: http containerPort: 9100 diff --git a/authbridge/demos/hr-cpex/k8s/30-agent.yaml b/authbridge/demos/hr-cpex/k8s/30-agent.yaml index 71757288e..b9ba876f1 100644 --- a/authbridge/demos/hr-cpex/k8s/30-agent.yaml +++ b/authbridge/demos/hr-cpex/k8s/30-agent.yaml @@ -121,6 +121,16 @@ spec: - name: agent image: hr-cpex-agent:dev imagePullPolicy: Never + # Baseline container hardening — drop OS privilege the agent + # never needs. (Non-root execution would require the image to + # add a USER; left out so the demo image stays minimal.) + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault env: - name: PORT value: "8001" @@ -170,6 +180,18 @@ spec: - name: authbridge-cpex image: authbridge-cpex:dev imagePullPolicy: Never + # The authbridge-cpex image already runs as a non-root user + # (USER 1001 in its Dockerfile), so enforce it here along with + # the baseline privilege drop. + securityContext: + runAsNonRoot: true + runAsUser: 1001 + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault args: - "--config" - "/etc/authbridge/authbridge.yaml" diff --git a/authbridge/demos/hr-cpex/scenarios/_lib.sh b/authbridge/demos/hr-cpex/scenarios/_lib.sh index d7f42ee15..b4b46b32f 100755 --- a/authbridge/demos/hr-cpex/scenarios/_lib.sh +++ b/authbridge/demos/hr-cpex/scenarios/_lib.sh @@ -1,3 +1,4 @@ +# shellcheck shell=bash # Shared helpers for the scenario scripts. Source from each script: # # source "$(dirname "$0")/_lib.sh" From 76286e8b550052a63f7fe39d44445c10e5700cd8 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 18:42:36 -0400 Subject: [PATCH 05/18] build(cpex): bump CPEX dependency to v0.2.0-alpha.1 Update the pinned CPEX FFI ABI and Go bindings from v0.2.0-ffi.test.8 to v0.2.0-alpha.1: - cmd/authbridge-cpex/CPEX_FFI_VERSION (drives the FFI archive download) - authlib + cmd/authbridge-cpex go.mod/go.sum (cpex Go bindings) The published FFI archive is sha256-verified (FFI_ABI=2); the -tags cpex cgo build links cleanly against the new bindings and 'make scenarios' passes all 9 hr-cpex demo cases on a kind cluster. Signed-off-by: Frederico Araujo --- authbridge/authlib/go.mod | 2 +- authbridge/authlib/go.sum | 4 +- .../cmd/authbridge-cpex/CPEX_FFI_VERSION | 2 +- authbridge/cmd/authbridge-cpex/go.mod | 3 +- authbridge/cmd/authbridge-cpex/go.sum | 69 ++++++++++++++++++- 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index 861af62a3..fd81b37da 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -3,7 +3,7 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib go 1.25.4 require ( - github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 + github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 github.com/envoyproxy/go-control-plane/envoy v1.37.0 github.com/fsnotify/fsnotify v1.10.1 github.com/gobwas/glob v0.2.3 diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 336f5cbb9..cea1eb11c 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -30,8 +30,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 h1:BDal9uwZ0tN92q3zfv2sXxlC0LYOPLiHz0wBzhJJKd0= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 h1:HWKqhOoh3dEscE8jWe0dYiOOegLW1liQmQ/RqBj7+Ks= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION index 901f7c060..cedb07d02 100644 --- a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION @@ -1 +1 @@ -v0.2.0-ffi.test.8 +v0.2.0-alpha.1 diff --git a/authbridge/cmd/authbridge-cpex/go.mod b/authbridge/cmd/authbridge-cpex/go.mod index 21850a73b..434403715 100644 --- a/authbridge/cmd/authbridge-cpex/go.mod +++ b/authbridge/cmd/authbridge-cpex/go.mod @@ -6,12 +6,13 @@ require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101 require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 // indirect + github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect diff --git a/authbridge/cmd/authbridge-cpex/go.sum b/authbridge/cmd/authbridge-cpex/go.sum index b40cdfff7..c621fd2a1 100644 --- a/authbridge/cmd/authbridge-cpex/go.sum +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -1,16 +1,37 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8 h1:BDal9uwZ0tN92q3zfv2sXxlC0LYOPLiHz0wBzhJJKd0= -github.com/contextforge-org/cpex/go/cpex v0.2.0-ffi.test.8/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= +github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 h1:HWKqhOoh3dEscE8jWe0dYiOOegLW1liQmQ/RqBj7+Ks= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -27,6 +48,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -43,10 +68,32 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/open-policy-agent/opa v1.4.2 h1:ag4upP7zMsa4WE2p1pwAFeG4Pn3mNwfAx9DLhhJfbjU= +github.com/open-policy-agent/opa v1.4.2/go.mod h1:DNzZPKqKh4U0n0ANxcCVlw8lCSv2c+h5G/3QvSYdWZ8= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ= +github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -54,12 +101,22 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM= +github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= @@ -74,10 +131,14 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= @@ -92,3 +153,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c= +oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= From 8898128a4958fc8ab0f633d43601227258fb339a Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 20:13:31 -0400 Subject: [PATCH 06/18] fix(cpex): address PR #493 review feedback on the hr-cpex demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the still-open review comments from PR #493 (the rest were handled in cd2e38b): - agent.py: guard HRAgent shared state (_histories, _request_id) with a threading.Lock — run_turn() runs on asyncio.to_thread workers and the A2A SDK can dispatch turns concurrently, which could corrupt the history map or lose a request-id increment. - verify-token-exchange.sh: check aud membership instead of the first array element, so a valid multi-audience token isn't a false failure. - scenarios/04, scenarios/06: capture-then-render so a transport failure surfaces (no '|| true' mask) and 'curl -i | head' can't SIGPIPE under 'set -o pipefail'. - cmd/authbridge-cpex/entrypoint.sh: set -eu -> set -euo pipefail. - k8s/10-keycloak.yaml: baseline container securityContext (allowPrivilegeEscalation:false, drop ALL, seccomp RuntimeDefault) to match 20-hr-mcp.yaml / 30-agent.yaml. - docs: add language tags to fenced code blocks and fix a heading-level increment in the cpex READMEs, cpex-plugin.md, and authbridge-hooks.md. Verified on a kind cluster: make scenarios 9/9, verify-token-exchange passes, A2A chat (bob allow / eve redact / alice deny) works. Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/README.md | 4 +-- authbridge/cmd/authbridge-cpex/entrypoint.sh | 2 +- authbridge/demos/hr-cpex/README.md | 10 +++---- authbridge/demos/hr-cpex/agent/agent.py | 28 ++++++++++++++----- authbridge/demos/hr-cpex/k8s/10-keycloak.yaml | 10 +++++++ .../scenarios/04-alice-internal-allow.sh | 8 ++++-- .../hr-cpex/scenarios/06-bob-apl-deny.sh | 8 ++++-- .../demos/hr-cpex/verify-token-exchange.sh | 12 ++++++-- authbridge/docs/cpex-plugin.md | 2 +- docs/proposals/authbridge-hooks.md | 2 +- 10 files changed, 62 insertions(+), 24 deletions(-) diff --git a/authbridge/authlib/plugins/cpex/README.md b/authbridge/authlib/plugins/cpex/README.md index 4c471eaa9..db78b3ac5 100644 --- a/authbridge/authlib/plugins/cpex/README.md +++ b/authbridge/authlib/plugins/cpex/README.md @@ -30,7 +30,7 @@ CPEX hooks the operator configured, applies any modifications CPEX returns (redacted bodies, mutated headers, session labels), and maps the CPEX outcome back into a pipeline action. -``` +```text request ─► token-exchange ─► parsers ─► cpex ─────────► upstream ─► response (MCP, │ inference, │ context @@ -185,7 +185,7 @@ a2a-parser]`, so `pipeline.Build` fails fast if no parser feeds it. The HR demo enforces on the agent's egress (the forward proxy), with inbound left as passthrough: -``` +```text outbound: mcp-parser ──► cpex ──► (token exchange happens as part of the policy via delegate) ``` diff --git a/authbridge/cmd/authbridge-cpex/entrypoint.sh b/authbridge/cmd/authbridge-cpex/entrypoint.sh index 6ec8ded06..7a54c86bd 100644 --- a/authbridge/cmd/authbridge-cpex/entrypoint.sh +++ b/authbridge/cmd/authbridge-cpex/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -eu +set -euo pipefail # AuthBridge CPEX-enabled sidecar entrypoint with process supervision. # Manages: authbridge-cpex (authbridge-proxy + CPEX plugin). diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md index 90bc50a38..5fe7e63ca 100644 --- a/authbridge/demos/hr-cpex/README.md +++ b/authbridge/demos/hr-cpex/README.md @@ -100,7 +100,7 @@ make scenarios # run the nine curl scenarios (the same egress path the agent's own tool calls take), exercising the personas across allow, deny, and redact paths: -``` +```text 01-bob-allow.sh PASS SSN visible 02-alice-deny.sh DENY APL require(role.hr) 03-eve-redact.sh PASS SSN redacted in response <- the one to watch @@ -222,7 +222,7 @@ In all cases Ollama must be started with `OLLAMA_HOST=0.0.0.0`. If In-chat commands: -``` +```text switch swap persona and re-mint both tokens (alice, bob, eve) relogin refresh both tokens if they expire mid-demo quit exit @@ -230,7 +230,7 @@ quit exit Suggested conversation: -``` +```text look up the compensation for EMP-001234, include the SSN -> 200 OK, SSN included @@ -263,7 +263,7 @@ tool call, **re-attaches the same headers** onto an MCP `tools/call` that egresses through the sidecar's **forward proxy** — where the `mcp-parser -> cpex` pipeline runs and `cpex` evaluates `cpex-policy.yaml` in order. -``` +```text chat.py (host A2A client) | message/send | Authorization: client token X-User-Token: persona token @@ -327,7 +327,7 @@ flat permission gate to a four-layer chain. permission directly on the user token. APL gates on a flat predicate and redacts the SSN on the wire when the permission is missing: -``` +```text require(role.hr) delegate(workday-oauth, target: workday-api, permissions: [read_compensation]) plugin(audit-log) diff --git a/authbridge/demos/hr-cpex/agent/agent.py b/authbridge/demos/hr-cpex/agent/agent.py index b541e4973..6f80f28b2 100644 --- a/authbridge/demos/hr-cpex/agent/agent.py +++ b/authbridge/demos/hr-cpex/agent/agent.py @@ -50,6 +50,7 @@ import json import logging import os +import threading import uuid from typing import Any @@ -323,13 +324,26 @@ class HRAgent: def __init__(self) -> None: self._histories: dict[str, list[dict[str, Any]]] = {} self._request_id = 0 + # The A2A SDK can dispatch turns concurrently, and run_turn() runs on + # worker threads (asyncio.to_thread). Guard the shared singleton state + # — the _histories map (get-or-create) and the _request_id counter — so + # concurrent turns can't corrupt the map or lose an increment. Distinct + # session ids get distinct history lists, so per-turn work stays + # lock-free; only these two shared touch-points are serialized. + self._state_lock = threading.Lock() def _history(self, session_id: str) -> list[dict[str, Any]]: - hist = self._histories.get(session_id) - if hist is None: - hist = [{"role": "system", "content": SYSTEM_PROMPT}] - self._histories[session_id] = hist - return hist + with self._state_lock: + hist = self._histories.get(session_id) + if hist is None: + hist = [{"role": "system", "content": SYSTEM_PROMPT}] + self._histories[session_id] = hist + return hist + + def _next_request_id(self) -> int: + with self._state_lock: + self._request_id += 1 + return self._request_id def run_turn( self, @@ -374,7 +388,7 @@ def run_turn( args = json.loads(fn.arguments) if isinstance(fn.arguments, str) else fn.arguments except json.JSONDecodeError: args = {} - self._request_id += 1 + request_id = self._next_request_id() log.info( "tool call (session=%s): %s(%s)", session_id, @@ -387,7 +401,7 @@ def run_turn( user_token=user_token, client_token=client_token, session_id=session_id, - request_id=self._request_id, + request_id=request_id, ) tool_text = format_tool_response(status, data) log.info("tool result (session=%s, http=%s): %s", session_id, status, tool_text) diff --git a/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml index 5f7e5982e..0a84c3533 100644 --- a/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml +++ b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml @@ -54,6 +54,16 @@ spec: containers: - name: keycloak image: quay.io/keycloak/keycloak:26.6.3 + # Baseline container hardening — matches 20-hr-mcp.yaml / 30-agent.yaml. + # (The Keycloak image already runs non-root; readOnlyRootFilesystem is + # left off because start-dev writes to the in-image H2 store + /tmp.) + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault args: - "start-dev" - "--import-realm" diff --git a/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh index e91161f06..9ff1564ef 100755 --- a/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh +++ b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh @@ -26,7 +26,10 @@ note " permissions claim includes repo:read:internal" ALICE=$(mint alice) CLIENT=$(mint hr-copilot) -curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ +# Capture then render so a transport/proxy failure surfaces (under `set -e`) +# instead of being swallowed by a trailing `|| true`. Pretty-print when the +# body is JSON; fall back to raw otherwise. +resp=$(curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CLIENT" \ -H "X-User-Token: $ALICE" \ @@ -38,4 +41,5 @@ curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ "name": "search_repos", "arguments": { "repo_name": "web-app", "visibility": "internal" } } - }' | jq . 2>/dev/null || true + }') +printf '%s\n' "$resp" | jq . 2>/dev/null || printf '%s\n' "$resp" diff --git a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh index 1c5ae36fb..5b845a0fb 100755 --- a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh @@ -26,7 +26,10 @@ note "Expected: Cedar never runs; IdP never called" BOB=$(mint bob) CLIENT=$(mint hr-copilot) -curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ +# Capture then truncate. Piping `curl -i | head` trips `pipefail` (head closes +# early → curl gets SIGPIPE → non-zero), so capture first and slice with sed +# (reads to EOF, never early-closes the pipe). +resp=$(curl -s -i -x "$PROXY" -X POST "$MCP_TARGET" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $CLIENT" \ -H "X-User-Token: $BOB" \ @@ -38,4 +41,5 @@ curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ "name": "search_repos", "arguments": { "visibility": "internal" } } - }' -i 2>&1 | head -20 + }' 2>&1) +printf '%s\n' "$resp" | sed -n '1,20p' diff --git a/authbridge/demos/hr-cpex/verify-token-exchange.sh b/authbridge/demos/hr-cpex/verify-token-exchange.sh index f8f6fa657..6616a69da 100755 --- a/authbridge/demos/hr-cpex/verify-token-exchange.sh +++ b/authbridge/demos/hr-cpex/verify-token-exchange.sh @@ -93,13 +93,19 @@ case $((${#payload} % 4)) in 3) payload="${payload}=" ;; esac decoded=$(printf "%s" "$payload" | tr '_-' '/+' | base64 -d 2>/dev/null || true) -aud=$(echo "$decoded" | jq -r 'if .aud | type == "array" then .aud[0] else .aud end' 2>/dev/null || echo "?") +# `aud` may be a string or an array; check MEMBERSHIP rather than the first +# element, so a valid multi-audience token (e.g. aud=["workday-api","account"]) +# isn't a false failure just because workday-api isn't listed first. +aud=$(echo "$decoded" | jq -rc '.aud // "?"' 2>/dev/null || echo "?") +aud_match=$(echo "$decoded" | jq -r --arg want "$AUDIENCE" \ + '(if (.aud | type) == "array" then .aud else [.aud] end) | index($want) != null' \ + 2>/dev/null || echo "false") sub=$(echo "$decoded" | jq -r '.sub // "?"' 2>/dev/null || echo "?") -if [ "$aud" = "$AUDIENCE" ]; then +if [ "$aud_match" = "true" ]; then ok "cpex-gateway → workday-api" "OK (aud=$aud)" else - fail "cpex-gateway → workday-api" "aud mismatch: expected '$AUDIENCE' got '$aud'" + fail "cpex-gateway → workday-api" "aud mismatch: expected to contain '$AUDIENCE' got '$aud'" exit 1 fi diff --git a/authbridge/docs/cpex-plugin.md b/authbridge/docs/cpex-plugin.md index 2ea47f9a1..e318b9705 100644 --- a/authbridge/docs/cpex-plugin.md +++ b/authbridge/docs/cpex-plugin.md @@ -11,7 +11,7 @@ A single AuthBridge plugin instance (`cpex`) wraps an entire CPEX AuthBridge phase via the `hooks` block; CPEX's own YAML defines the sub-plugins those hooks dispatch to. -``` +```text ┌──────────────────────────────────────────────────────────────────┐ │ authbridge-cpex (binary built with -tags cpex, links libcpex_ffi)│ │ │ diff --git a/docs/proposals/authbridge-hooks.md b/docs/proposals/authbridge-hooks.md index a7ea7d669..3a01129f5 100644 --- a/docs/proposals/authbridge-hooks.md +++ b/docs/proposals/authbridge-hooks.md @@ -5,7 +5,7 @@ This document specifies the hook system and plugin runtime for AuthBridge. Hooks provide typed, capability-gated extension points at well-defined stages of the inbound JWT validation and outbound token exchange pipelines. The plugin runtime is built on [CPEX](https://github.com/contextforge-org/contextforge-plugins-framework/tree/main), embedded in-process via Go bindings to the Rust core. -### How it works +## How it works AuthBridge defines **hooks** (named extension points at each stage of inbound and outbound processing) and their **typed payloads** (the data available at each hook). At startup, AuthBridge imports and initializes a CPEX `PluginManager`, which loads configured plugins and registers their hook subscriptions. On each request, AuthBridge constructs the payload and extensions at each hook point and hands them to CPEX. CPEX handles plugin dispatch (phase ordering, parallel execution, capability gating) and returns an aggregate `PluginResult`. AuthBridge then applies the result, enforcing tighten-only composition, short-circuit behavior, and error handling. From 014c85bb4c284593b9b735a2bd9a62275612fa62 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 10 Jun 2026 20:25:51 -0400 Subject: [PATCH 07/18] fix(cpex): roll rebuilt images on `make deploy` (same-tag :dev footgun) make deploy rebuilds + kind-loads the local :dev images, but a Deployment whose pod template is unchanged won't roll on its own (imagePullPolicy: Never, no spec diff), so a code-only rebuild kept running the previous image until a manual 'kubectl rollout restart'. deploy now: waits for Keycloak to settle first, then 'rollout restart's the two locally-built workloads (hr-mcp; hr-cpex-agent, which carries the agent + authbridge-cpex sidecar) so a rebuild always lands. Keycloak is left alone (upstream pinned image; restarting it regenerates realm keys and re-triggers the stale-JWKS / cpex.auth_unknown_kid race the settle-wait guards against). Verified: 'make deploy' then 'make scenarios' (no manual rollout) -> 9/9, 0 auth_unknown_kid. Signed-off-by: Frederico Araujo --- authbridge/demos/hr-cpex/Makefile | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile index e991c2a84..2375090ee 100644 --- a/authbridge/demos/hr-cpex/Makefile +++ b/authbridge/demos/hr-cpex/Makefile @@ -116,13 +116,24 @@ apply: ## kubectl apply the manifests (assumes images already loaded) deploy: build-images load-images apply ## Full bring-up: build, load, apply @echo - @echo "Waiting for rollouts to finish..." - @# rollout status follows the new ReplicaSet, so it's correct even when the - @# `set env` in `apply` rolls the agent pod (a plain label wait can race a - @# terminating old pod against the new one). - kubectl -n $(NAMESPACE) rollout status deployment/keycloak --timeout=180s - kubectl -n $(NAMESPACE) rollout status deployment/hr-mcp --timeout=120s - kubectl -n $(NAMESPACE) rollout status deployment/hr-cpex-agent --timeout=120s + @# Settle Keycloak FIRST so the cpex sidecar (restarted below) fetches a + @# current JWKS. `apply` rolls Keycloak only when its manifest changed; this + @# waits either way. We do NOT restart Keycloak — it's an upstream pinned + @# image, and restarting it regenerates the realm signing keys, which would + @# leave any already-running sidecar serving cpex.auth_unknown_kid. + @echo "Waiting for Keycloak to settle..." + kubectl -n $(NAMESPACE) rollout status deployment/keycloak --timeout=180s + @echo + @# Force the locally-built :dev images to reload. `kind load` overwrites the + @# image in the node store, but a Deployment whose pod template is unchanged + @# won't roll on its own (imagePullPolicy: Never, no spec diff) — so a + @# code-only rebuild would otherwise keep running the PREVIOUS image. Restart + @# the two locally-built workloads (hr-mcp; hr-cpex-agent carries both the + @# agent and the authbridge-cpex sidecar) so a rebuild always lands. + @echo "Rolling the locally-built workloads onto the freshly-loaded images..." + kubectl -n $(NAMESPACE) rollout restart deployment/hr-mcp deployment/hr-cpex-agent + kubectl -n $(NAMESPACE) rollout status deployment/hr-mcp --timeout=120s + kubectl -n $(NAMESPACE) rollout status deployment/hr-cpex-agent --timeout=120s @echo @printf "\033[1;32mDeploy complete.\033[0m Next:\n" @printf " make port-forward # in another terminal\n" From e0676fd5518918f2c6097a75609a2b2606714a89 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 11 Jun 2026 00:19:22 -0400 Subject: [PATCH 08/18] =?UTF-8?q?feat(cpex):=20harden=20hr-cpex=20chat=20d?= =?UTF-8?q?emo=20=E2=80=94=20faithful=20rendering,=20--show-tools,=20Clien?= =?UTF-8?q?tFactory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterate the A2A chat demo into a reliable, observable walkthrough: agent.py: - temperature=0 + hardened 'relay tool data verbatim' prompt so the model stops fabricating [REDACTED] for an allowed SSN (bob). - Drop the optional 'ssn' echo-back tool arg: models populated it inconsistently ('' / null / omitted) and a null tripped cpex's APL redact(args.ssn) type check -> 403. The SSN demo is response-side (include_ssn=true -> redact(result.ssn)); request-side args.ssn redaction stays covered by scenarios/01-bob-allow.sh. - include_ssn contract (tool-arg description + prompt): set true ONLY when the user explicitly asks for the SSN, so a plain 'look up compensation' no longer leaks it. - Attach a per-turn tool trace (name/args/status/result) to the A2A response message metadata for optional client display. chat.py: - Migrate off the deprecated A2AClient to ClientFactory; per-call identity headers are injected via a ClientCallInterceptor + ClientCallContext (the new client has no http_kwargs). Removes the deprecation warning. - Add --show-tools (default off): render the agent's cpex-governed tool calls and results, indented, tool name in color, result green (allow) / red (deny). Default model -> ollama/llama3:latest (tool-calls on plain ollama/, renders faithfully at temp=0, no 'thinking' latency). Makefile documents ollama/qwen3 (richer prose, slower) and llama3.2:3b (fastest, occasionally noisy) as alternatives. README + CHAT-WALKTHROUGH updated. Verified on a kind cluster: bob real SSN, eve [REDACTED], alice cpex 403; include_ssn only set when explicitly asked; no client deprecation warning. Signed-off-by: Frederico Araujo --- authbridge/demos/hr-cpex/Makefile | 7 +- authbridge/demos/hr-cpex/README.md | 6 +- .../demos/hr-cpex/agent/CHAT-WALKTHROUGH.md | 2 +- authbridge/demos/hr-cpex/agent/agent.py | 99 +++++++--- authbridge/demos/hr-cpex/agent/chat.py | 187 ++++++++++++------ authbridge/demos/hr-cpex/k8s/30-agent.yaml | 2 +- 6 files changed, 208 insertions(+), 95 deletions(-) diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile index 2375090ee..e08205fec 100644 --- a/authbridge/demos/hr-cpex/Makefile +++ b/authbridge/demos/hr-cpex/Makefile @@ -50,7 +50,12 @@ AGENT_SRC ?= $(CURDIR)/agent # Override per-env, e.g.: make deploy LLM_API_BASE=http://192.168.5.2:11434 # MODEL — any litellm-routed model the host Ollama has pulled. LLM_API_BASE ?= http://host.docker.internal:11434 -MODEL ?= ollama/llama3.2:3b +# llama3 (8B) tool-calls on the plain ollama/ provider and renders tool data +# faithfully at temperature=0 (the agent sets temp=0) — no "thinking" overhead, +# so it's notably faster than qwen3. Alternatives: ollama/qwen3 (a "thinking" +# model — richer prose but slower); llama3.2:3b is fastest but a 3B model +# occasionally editorializes/redacts fields in its prose. +MODEL ?= ollama/llama3:latest .PHONY: help build-images load-images apply deploy port-forward scenarios logs undeploy preflight ollama-check diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md index 5fe7e63ca..bfd7c1b58 100644 --- a/authbridge/demos/hr-cpex/README.md +++ b/authbridge/demos/hr-cpex/README.md @@ -72,7 +72,7 @@ install Kagenti or create a cluster. You need: to a registry. - kubectl, configured for the cluster. - An Ollama running on the host with a tool-capable model pulled - (`ollama pull llama3.2:3b`) — only needed for the interactive chat; + (`ollama pull llama3`) — only needed for the interactive chat; the `make scenarios` curl matrix does not use the LLM. Ollama must listen beyond loopback so cluster pods can reach it: set `OLLAMA_HOST=0.0.0.0` and restart Ollama. How a pod reaches the host @@ -161,12 +161,12 @@ tokens and switches persona mid-conversation. The LLM and the tools live in the agent container; `chat.py` only sends `message/send` and renders the reply. -The agent itself runs an LLM (default `ollama/llama3.2:3b`). Make sure an +The agent itself runs an LLM (default `ollama/llama3:latest`). Make sure an Ollama is running on the host with the model pulled and listening beyond loopback so the cluster can reach it: ```bash -ollama pull llama3.2:3b +ollama pull llama3 OLLAMA_HOST=0.0.0.0 ollama serve # or, for the macOS app: # launchctl setenv OLLAMA_HOST 0.0.0.0 && relaunch Ollama ``` diff --git a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md index a8ae7d987..6bcf7be33 100644 --- a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md +++ b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md @@ -35,7 +35,7 @@ In a fourth terminal (the demo itself): ```bash cd agent # one-time: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements-client.txt -# Ollama must be running on the host with the model pulled (ollama pull llama3.2:3b). +# Ollama must be running on the host with the model pulled (ollama pull llama3). python chat.py --persona bob # or: AGENT_URL=http://localhost:8082 python chat.py --persona bob ``` diff --git a/authbridge/demos/hr-cpex/agent/agent.py b/authbridge/demos/hr-cpex/agent/agent.py index 6f80f28b2..046619ac6 100644 --- a/authbridge/demos/hr-cpex/agent/agent.py +++ b/authbridge/demos/hr-cpex/agent/agent.py @@ -62,7 +62,7 @@ from a2a.server.events import EventQueue from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from a2a.types import AgentCapabilities, AgentCard, AgentSkill, Message, Part, Role, TextPart from a2a.utils import new_agent_text_message logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper()) @@ -81,7 +81,7 @@ MCP_PROXY = os.environ.get("MCP_PROXY", "http://localhost:8081") # litellm-routed model. Defaults to local Ollama (no API key). Override # with any LiteLLM-supported provider via MODEL + the provider's env. -MODEL = os.environ.get("MODEL", "ollama/llama3.2:3b") +MODEL = os.environ.get("MODEL", "ollama/llama3:latest") # Inference endpoint. For the ollama provider this is the native base # (litellm appends /api/...). Set to host.docker.internal in-cluster so # the agent reaches an Ollama running on the developer's laptop. @@ -90,9 +90,10 @@ AGENT_PUBLIC_URL = os.environ.get("AGENT_PUBLIC_URL", "http://hr-cpex-agent.cpex-demo:8080/") # --------------------------------------------------------------------------- -# Tools + prompt (lifted verbatim from the original chat.py — the gateway -# enforcement story depends on these exact tool shapes, e.g. the `ssn` -# echo-back arg that redaction strips). +# Tools + prompt (lifted from the original chat.py). The SSN demo is +# response-side: include_ssn=true → the gateway redacts result.ssn for callers +# without view_ssn. The hardened prompt makes the model relay tool values +# verbatim so it doesn't fabricate `[REDACTED]` for a real SSN. # --------------------------------------------------------------------------- SYSTEM_PROMPT = ( @@ -100,14 +101,26 @@ "employee compensation, view directories, send emails, and similar " "tasks. Use the provided tools when needed. " "\n\n" + "Only request data the user actually asked for: in particular, set " + "get_compensation's `include_ssn` to true ONLY when the user explicitly " + "asks to include/show the SSN. If the user just asks to look up " + "compensation without mentioning the SSN, leave `include_ssn` false. " + "\n\n" + "CRITICAL — relay tool data verbatim: when you present a field, copy its " + "value EXACTLY as it appears in the tool result. Never invent, mask, " + "redact, or replace a value yourself. Only write `[REDACTED]` for a field " + "if the tool result's value for that field is literally the string " + "`[REDACTED]`; when the tool returns a real value (for example an actual " + "social-security number), show that exact value unchanged. The gateway — " + "not you — decides what to hide; your job is to relay precisely what it " + "returned. " + "\n\n" "How to interpret tool results: " "\n" - " * If the tool returns a normal result, present the data to the " - "user. If any field's value is the literal string `[REDACTED]`, " - "show it as-is in your answer — that is the gateway's transparent " - "enforcement marker that the field exists but is hidden for this " - "caller. Do NOT apologize or refuse; just include the field with " - "the value `[REDACTED]`. " + " * Normal result: present the data, copying each value verbatim per the " + "rule above. A field whose value is `[REDACTED]` is the gateway's " + "transparent enforcement marker (the field exists but is hidden for this " + "caller) — show it as-is; do NOT apologize or refuse. " "\n" " * If the tool returns an `error` envelope (a JSON-RPC error " "with a `code` and `message`), the gateway denied the call. " @@ -135,18 +148,22 @@ }, "include_ssn": { "type": "boolean", - "description": "Whether to include SSN in the response", - "default": False, - }, - "ssn": { - "type": "string", "description": ( - "An echo-back of the employee's SSN if the caller " - "claims to already know it — this is exactly the " - "kind of field the gateway redacts when the " - "caller lacks the necessary permission." + "Whether to include the SSN in the response. Set to " + "true ONLY when the user explicitly asks for the SSN " + "(e.g. 'include the SSN'). If the user does not " + "mention the SSN, omit this or set it to false." ), + "default": False, }, + # NB: no `ssn` request argument is exposed. The SSN demo is + # response-side: set include_ssn=true and the gateway redacts + # result.ssn for callers without view_ssn. An `ssn` echo-back + # arg used to live here, but models populate it + # inconsistently (""/null/omitted) and a null value trips the + # APL redact(args.ssn) type check (expected Str) → 403. The + # request-side args.ssn redaction is still exercised by the + # deterministic curl matrix (scenarios/01-bob-allow.sh). }, "required": ["employee_id"], }, @@ -352,12 +369,24 @@ def run_turn( user_token: str, client_token: str, session_id: str, - ) -> str: - """One user turn: LLM (direct) → tool calls (proxied/cpex) → LLM.""" + ) -> tuple[str, list[dict[str, Any]]]: + """One user turn: LLM (direct) → tool calls (proxied/cpex) → LLM. + + Returns (reply_text, tool_trace). tool_trace is a list of + {name, args, status, text} records — one per cpex-governed tool call — + which the executor attaches to the A2A response metadata so a client + can optionally render what happened on the wire (see chat.py + --show-tools). The trace is always collected; the client decides + whether to display it.""" messages = self._history(session_id) messages.append({"role": "user", "content": user_text}) - completion_kwargs: dict[str, Any] = {} + # temperature=0: deterministic decoding. The demo needs the model to + # relay tool values verbatim (not creatively re-word or redact), so the + # lowest-temperature, highest-probability completion is what we want — + # it measurably reduces a small model's tendency to fabricate + # `[REDACTED]` or editorialize fields. + completion_kwargs: dict[str, Any] = {"temperature": 0} if LLM_API_BASE: completion_kwargs["api_base"] = LLM_API_BASE @@ -372,15 +401,16 @@ def run_turn( except Exception as e: # noqa: BLE001 — surface any LLM error to the user messages.pop() log.warning("LLM error: %s", e) - return f"(LLM error: {e})" + return f"(LLM error: {e})", [] assistant = response.choices[0].message if not assistant.tool_calls: text = assistant.content or "(no response)" messages.append({"role": "assistant", "content": text}) - return text + return text, [] # Tool-call path: replay each call through cpex, then summarize. + tool_trace: list[dict[str, Any]] = [] messages.append(assistant.model_dump()) for tc in assistant.tool_calls: fn = tc.function @@ -406,6 +436,7 @@ def run_turn( tool_text = format_tool_response(status, data) log.info("tool result (session=%s, http=%s): %s", session_id, status, tool_text) messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_text}) + tool_trace.append({"name": fn.name, "args": args, "status": status, "text": tool_text}) try: final = litellm.completion(model=MODEL, messages=messages, **completion_kwargs) @@ -413,7 +444,7 @@ def run_turn( except Exception as e: # noqa: BLE001 text = f"(LLM error summarizing tool results: {e})" messages.append({"role": "assistant", "content": text}) - return text + return text, tool_trace # --------------------------------------------------------------------------- @@ -459,14 +490,26 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non log.info("A2A turn (session=%s): %s", session_id, user_text) # litellm + httpx are synchronous; run off the event loop so we # don't block the server while the model and tools work. - reply = await asyncio.to_thread( + reply, tool_trace = await asyncio.to_thread( self._agent.run_turn, user_text, user_token=user_token, client_token=authorization, session_id=session_id, ) - await event_queue.enqueue_event(new_agent_text_message(reply, context.context_id, context.task_id)) + # Carry the per-turn tool trace in the response message metadata so a + # client can optionally show what hit cpex/hr-mcp (see chat.py + # --show-tools). new_agent_text_message has no metadata param, so build + # the Message directly. + message = Message( + role=Role.agent, + parts=[Part(root=TextPart(text=reply))], + message_id=uuid.uuid4().hex, + context_id=context.context_id, + task_id=context.task_id, + metadata={"tool_trace": tool_trace}, + ) + await event_queue.enqueue_event(message) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: # Single-shot turns; nothing long-running to cancel. diff --git a/authbridge/demos/hr-cpex/agent/chat.py b/authbridge/demos/hr-cpex/agent/chat.py index 3060d6717..eeadda3ea 100755 --- a/authbridge/demos/hr-cpex/agent/chat.py +++ b/authbridge/demos/hr-cpex/agent/chat.py @@ -46,23 +46,26 @@ import argparse import asyncio +import json import os import sys import uuid import httpx -from a2a.client import A2AClient +from a2a.client import ClientConfig, ClientFactory +from a2a.client.middleware import ClientCallContext, ClientCallInterceptor from a2a.types import ( + AgentCapabilities, + AgentCard, Message, - MessageSendParams, Part, Role, - SendMessageRequest, Task, TextPart, ) from a2a.utils import get_message_text from rich.console import Console +from rich.markup import escape from rich.panel import Panel # --------------------------------------------------------------------------- @@ -191,6 +194,46 @@ def new_session_id(persona: str) -> str: return f"chat-{persona}-{uuid.uuid4().hex[:8]}" +class _HeaderInterceptor(ClientCallInterceptor): + """Inject the per-call identity headers into the outgoing HTTP request. + + The modern a2a-sdk client (ClientFactory) has no per-call `http_kwargs`, + so identity that changes per turn (persona token, client token, session + id) is passed via the ClientCallContext `state` and merged into the HTTP + headers here. The agent reads them off the inbound request and re-attaches + them onto its cpex-governed tool calls; contextId carries the same session + id inside the A2A envelope as a belt-and-suspenders for taint scoping.""" + + async def intercept(self, method_name, request_payload, http_kwargs, agent_card, context): + headers = context.state.get("headers") if context else None + if headers: + merged = dict(http_kwargs.get("headers") or {}) + merged.update(headers) + http_kwargs = {**http_kwargs, "headers": merged} + return request_payload, http_kwargs + + +def _agent_card(agent_url: str) -> AgentCard: + """Minimal local card so ClientFactory targets `agent_url` over JSON-RPC. + + We build the card locally rather than fetching `/.well-known/agent-card.json` + because the agent advertises its in-cluster Service URL there, which isn't + reachable from the host — the client connects via the port-forward + (`agent_url`), so that's the URL the transport must use.""" + return AgentCard( + name="hr-cpex-agent", + description="HR copilot A2A agent (CPEX-governed egress).", + url=agent_url, + version="0.1.0", + protocol_version="0.3.0", + preferred_transport="JSONRPC", + default_input_modes=["text"], + default_output_modes=["text"], + capabilities=AgentCapabilities(streaming=False), + skills=[], + ) + + async def send_turn( agent_url: str, user_text: str, @@ -198,62 +241,77 @@ async def send_turn( user_token: str, client_token: str, session_id: str, -) -> str: - """Send one message/send to the agent and return the reply text. - - The identity headers ride on the HTTP request via `http_kwargs`; the - agent reads them off the inbound request and threads them onto its - cpex-governed tool calls. contextId carries the same session id inside - the A2A envelope as a belt-and-suspenders for taint scoping.""" +) -> tuple[str, list[dict]]: + """Send one message/send to the agent. + + Returns (reply_text, tool_trace). The agent stows a per-turn tool trace + (the cpex-governed tool calls + their results) in the response message + metadata under `tool_trace`; we surface it so the caller can optionally + render it (see --show-tools).""" + message = Message( + role=Role.user, + parts=[Part(root=TextPart(text=user_text))], + message_id=uuid.uuid4().hex, + context_id=session_id, + ) + context = ClientCallContext( + state={ + "headers": { + "X-User-Token": user_token, + "Authorization": f"Bearer {client_token}", + "X-Session-Id": session_id, + } + } + ) + reply = "" + tool_trace: list[dict] = [] async with httpx.AsyncClient(timeout=60) as httpx_client: - client = A2AClient(httpx_client=httpx_client, url=agent_url) - request = SendMessageRequest( - id=uuid.uuid4().hex, - params=MessageSendParams( - message=Message( - role=Role.user, - parts=[Part(root=TextPart(text=user_text))], - message_id=uuid.uuid4().hex, - context_id=session_id, - ) - ), - ) - response = await client.send_message( - request, - http_kwargs={ - "headers": { - "X-User-Token": user_token, - "Authorization": f"Bearer {client_token}", - "X-Session-Id": session_id, - } - }, - ) - return _extract_reply(response) - - -def _extract_reply(response) -> str: - """Pull the agent's text out of a SendMessageResponse (Message or Task).""" - root = getattr(response, "root", response) - error = getattr(root, "error", None) - if error is not None: - return f"(agent error {error.code}: {error.message})" - result = getattr(root, "result", None) - if result is None: - return "(no result)" - if isinstance(result, Task): - # Prefer the status message; fall back to the first artifact. - status_msg = getattr(result.status, "message", None) - if status_msg is not None: - text = get_message_text(status_msg) - if text: - return text - for artifact in result.artifacts or []: - text = "".join(p.root.text for p in artifact.parts if isinstance(p.root, TextPart)) - if text: - return text - return "(task completed, no text)" - # Message result - return get_message_text(result) or "(empty reply)" + factory = ClientFactory(ClientConfig(httpx_client=httpx_client, streaming=False)) + client = factory.create(_agent_card(agent_url), interceptors=[_HeaderInterceptor()]) + # Non-streaming send yields a single result: either a Message or a + # (Task, update) tuple. Extract the agent's text + tool_trace metadata. + async for event in client.send_message(message, context=context): + if isinstance(event, Message): + reply = get_message_text(event) or reply + if event.metadata and event.metadata.get("tool_trace"): + tool_trace = event.metadata["tool_trace"] + elif isinstance(event, tuple): + reply = _text_from_task(event[0]) or reply + return (reply or "(empty reply)"), tool_trace + + +def render_tool_trace(console: Console, trace: list[dict]) -> None: + """Print the agent's tool calls + results, indented, with the tool name + colored and the result colored by outcome (green allow / red deny).""" + for call in trace: + name = call.get("name", "?") + args = json.dumps(call.get("args", {}), separators=(",", ":")) + status = call.get("status") + text = call.get("text", "") + color = "green" if isinstance(status, int) and status < 400 else "red" + # escape() so JSON brackets in args/results aren't parsed as rich markup + # (and so a literal `[REDACTED]` renders as-is, not as a style tag). + console.print(f" [bold cyan]→ {escape(name)}[/]([dim]{escape(args)}[/])") + # First result line after the arrow; subsequent (pretty-JSON) lines are + # indented to align under it so multi-line results stay readable. + lines = text.splitlines() or [""] + console.print(f" [{color}]← {escape(lines[0])}[/]") + for line in lines[1:]: + console.print(f" [{color}]{escape(line)}[/]") + + +def _text_from_task(task: Task) -> str: + """Pull text out of an A2A Task result (status message, then artifacts).""" + status_msg = getattr(task.status, "message", None) if task.status else None + if status_msg is not None: + text = get_message_text(status_msg) + if text: + return text + for artifact in task.artifacts or []: + text = "".join(p.root.text for p in artifact.parts if isinstance(p.root, TextPart)) + if text: + return text + return "" # --------------------------------------------------------------------------- @@ -261,7 +319,7 @@ def _extract_reply(response) -> str: # --------------------------------------------------------------------------- -def run_chat(persona: str, agent_url: str, keycloak_host: str) -> None: +def run_chat(persona: str, agent_url: str, keycloak_host: str, show_tools: bool = False) -> None: console = Console() info = PERSONAS[persona] @@ -357,7 +415,7 @@ def run_chat(persona: str, agent_url: str, keycloak_host: str) -> None: continue try: - reply = asyncio.run( + reply, tool_trace = asyncio.run( send_turn( agent_url, user_input, @@ -369,6 +427,8 @@ def run_chat(persona: str, agent_url: str, keycloak_host: str) -> None: except httpx.HTTPError as e: console.print(f"[red]agent call failed: {e}[/red]") continue + if show_tools and tool_trace: + render_tool_trace(console, tool_trace) console.print(f"[bold]assistant:[/bold] {reply}\n") @@ -395,8 +455,13 @@ def main() -> int: default=os.environ.get("KEYCLOAK_HOST", DEFAULT_KEYCLOAK), help=f"Keycloak host (default: {DEFAULT_KEYCLOAK})", ) + p.add_argument( + "--show-tools", + action="store_true", + help="Show the agent's cpex-governed tool calls and their results (indented, colored) before each reply", + ) args = p.parse_args() - run_chat(args.persona, args.agent, args.keycloak) + run_chat(args.persona, args.agent, args.keycloak, show_tools=args.show_tools) return 0 diff --git a/authbridge/demos/hr-cpex/k8s/30-agent.yaml b/authbridge/demos/hr-cpex/k8s/30-agent.yaml index b9ba876f1..b77988088 100644 --- a/authbridge/demos/hr-cpex/k8s/30-agent.yaml +++ b/authbridge/demos/hr-cpex/k8s/30-agent.yaml @@ -150,7 +150,7 @@ spec: # Desktop the host is reached at http://192.168.5.2:11434 # (lima host-gateway) and Ollama must bind 0.0.0.0. - name: MODEL - value: "ollama/llama3.2:3b" + value: "ollama/llama3:latest" - name: LLM_API_BASE value: "http://host.docker.internal:11434" - name: AGENT_PUBLIC_URL From 5cbaa41e7af7acf2666569c73e4c91364730edda Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 12 Jun 2026 11:13:42 -0400 Subject: [PATCH 09/18] =?UTF-8?q?fix(cpex):=20address=20PR=20#493=20review?= =?UTF-8?q?=20=E2=80=94=20multi-part=20redaction=20leak=20+=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve Hai Huang's review findings on the CPEX plugin PR: Must-fix (blocker): - applyA2AResponseBodyMod: fail closed when >1 text-kind artifact parts exist, mirroring the inference response guard (cmf_inference.go:204). Previously only the first part was rewritten; parts[1..] forwarded the original unredacted content. Security/correctness: - Decision zero-value flipped from Allow to a fail-closed Unknown sentinel (DecisionUnknown = iota). An uninitialised Result never silently allows traffic. - FakeManager default changed from Allow to Deny for test fidelity. - Streaming (SSE) response gap detection: when the response body is non-empty but yields zero CMF parts (unparseable SSE), return DecisionError so fail_open governs rather than silently allowing. - Dockerfile: cosign verify-blob of the FFI .a asset at build time (sha256 + signature verification). Runtime ABI assertion already exists in the Go bindings. Nits: - Stale "not yet implemented" comment removed (manager_cpex.go:269). - IBM/... → contextforge-org/... import path comment fixed. - realm-export.json: _WARNING field flagging demo-only credentials. - 30-agent.yaml: model pinned from :latest to ollama/llama3.2:8b. - README + cpex-plugin.md: clarify authbridge-cpex is a build variant (not a second sidecar); document OPA dual presence. Verified: go test ./plugins/cpex/ passes (33 tests); go vet clean; full hr-cpex demo 9/9 scenarios pass on kind cluster. Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/README.md | 15 ++++ authbridge/authlib/plugins/cpex/cmf_a2a.go | 59 +++++++++------ .../authlib/plugins/cpex/cmf_a2a_test.go | 28 ++++++++ authbridge/authlib/plugins/cpex/cmf_body.go | 11 +++ .../authlib/plugins/cpex/cmf_body_test.go | 71 +++++++++++++++++++ authbridge/authlib/plugins/cpex/manager.go | 12 +++- .../authlib/plugins/cpex/manager_cpex.go | 16 ++++- .../authlib/plugins/cpex/manager_test.go | 9 ++- .../authlib/plugins/cpex/plugin_test.go | 20 ++++++ authbridge/cmd/authbridge-cpex/Dockerfile | 26 +++++-- authbridge/demos/hr-cpex/k8s/30-agent.yaml | 2 +- .../demos/hr-cpex/k8s/realm-export.json | 1 + authbridge/docs/cpex-plugin.md | 4 ++ 13 files changed, 237 insertions(+), 37 deletions(-) diff --git a/authbridge/authlib/plugins/cpex/README.md b/authbridge/authlib/plugins/cpex/README.md index db78b3ac5..8598f6d32 100644 --- a/authbridge/authlib/plugins/cpex/README.md +++ b/authbridge/authlib/plugins/cpex/README.md @@ -1,5 +1,10 @@ # CPEX +> **Deployment note:** `authbridge-cpex` is a **build variant** of the +> AuthBridge proxy-sidecar (`authbridge-proxy`), deployed *in place of* +> `authbridge-proxy` — not an additional sidecar. The operator selects +> the image when CPEX policy enforcement is needed for a workload. + The CPEX plugin routes AuthBridge pipeline hooks through the [CPEX](https://github.com/contextforge-org/cpex) framework, so operators can define authorization flows inline and declaratively, and turn decisions into ordered effects (including invoking CPEX plugins). @@ -140,6 +145,16 @@ policy: Local structural checks run fast and first; external PDPs run when the local checks pass; their decisions feed the same `on_deny` / `on_allow` effect lists. +> **OPA in two places.** AuthBridge ships a native `opa` pipeline plugin +> (standalone, no CPEX dependency) for simple per-request OPA checks. +> The CPEX `opa(...)` step shown above runs OPA as a CPEX sub-plugin — +> within APL's effect framework, so its verdict feeds `on_deny`/`on_allow` +> effects, session tainting, and ordered composition with other PDPs. +> Use the native `opa` plugin for standalone binary OPA gates; use +> CPEX's `opa(...)` when OPA participates in a multi-PDP orchestration +> flow. The `authzen(...)` and `cedar` steps are doc-level BYOP examples +> not exercised in this demo. + ## CMF / extension mapping The AuthBridge pipeline context is mapped into the CMF Message plus diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a.go b/authbridge/authlib/plugins/cpex/cmf_a2a.go index 17f7b77c2..e6bb3ac27 100644 --- a/authbridge/authlib/plugins/cpex/cmf_a2a.go +++ b/authbridge/authlib/plugins/cpex/cmf_a2a.go @@ -139,18 +139,18 @@ func applyA2ARequestBodyMod(pctx *pipeline.Context, newTexts []string) (mutated } // applyA2AResponseBodyMod rewrites pctx.ResponseBody — a non-streaming -// A2A JSON-RPC response — replacing the first artifact text part with -// newArtifact (the single redacted text part CPEX returned on the -// response phase). +// A2A JSON-RPC response — replacing the single artifact text part with +// newArtifact (the redacted text CPEX returned on the response phase). // -// A2A responses are frequently SSE streams (message/stream); those won't -// parse as one JSON object, so a requested redaction can't be applied and -// we return an error to fail closed rather than forward unredacted output. -// For the non-streaming JSON-RPC shape it rewrites the first text part -// under result.artifacts[].parts[]. +// Because the write side receives only one redacted text while the read +// side (a2aResponseParts) emits one text part per non-empty text-kind +// artifact part, a response carrying more than one such part is an +// ambiguous single-value rewrite — we fail closed rather than overwrite +// only the first part and forward the rest unredacted. Streaming (SSE) +// responses don't parse as one JSON object and also fail closed. // -// Returns mutated=false (no error) when newArtifact is empty or there's no -// artifact text part to replace. +// Returns mutated=false (no error) when newArtifact is empty or there's +// no artifact text part to replace. func applyA2AResponseBodyMod(pctx *pipeline.Context, newArtifact string) (mutated bool, err error) { if len(pctx.ResponseBody) == 0 || newArtifact == "" { return false, nil @@ -168,6 +168,11 @@ func applyA2AResponseBodyMod(pctx *pipeline.Context, newArtifact string) (mutate return false, nil } + // Collect all non-empty text-kind artifact parts, matching what the + // read side surfaced. We only hold one redacted text, so >1 such + // part is ambiguous: fail closed instead of stamping the same value + // over distinct parts or rewriting only the first. + targets := make([]map[string]any, 0, 4) for _, a := range artifacts { artifact, ok := a.(map[string]any) if !ok { @@ -185,21 +190,31 @@ func applyA2AResponseBodyMod(pctx *pipeline.Context, newArtifact string) (mutate if kind, _ := po["kind"].(string); kind != "text" { continue } - // Skip empty text parts so we rewrite the same part the read - // side surfaced (a2aResponseParts emits only non-empty text). - if t, ok := po["text"].(string); !ok || t == "" { - continue - } - po["text"] = newArtifact - newBody, err := json.Marshal(envelope) - if err != nil { - return false, fmt.Errorf("re-serialize A2A response body: %w", err) + if t, ok := po["text"].(string); ok && t != "" { + targets = append(targets, po) } - pctx.SetResponseBody(newBody) - return true, nil } } - return false, nil + + if len(targets) == 0 { + return false, nil + } + if len(targets) > 1 { + return false, fmt.Errorf( + "A2A response has %d text parts; single-value redaction rewrite is ambiguous", + len(targets)) + } + if targets[0]["text"] == newArtifact { + return false, nil + } + targets[0]["text"] = newArtifact + + newBody, err := json.Marshal(envelope) + if err != nil { + return false, fmt.Errorf("re-serialize A2A response body: %w", err) + } + pctx.SetResponseBody(newBody) + return true, nil } // a2aMessageParts navigates a decoded A2A JSON-RPC request envelope to diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go index e4953df4b..0be698789 100644 --- a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go +++ b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go @@ -122,6 +122,34 @@ func TestA2AResponseBodyMod_RewritesArtifact(t *testing.T) { } } +func TestA2AResponseBodyMod_MultiPartFailsClosed(t *testing.T) { + // Two text-kind artifact parts → ambiguous single-value rewrite → error. + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"part one"},{"kind":"text","text":"part two"}]}]}}`), + } + mutated, err := applyA2AResponseBodyMod(pctx, "redacted") + if err == nil { + t.Fatal("expected fail-closed error for multi-part response, got nil") + } + if mutated { + t.Fatal("mutated=true on multi-part fail-closed") + } +} + +func TestA2AResponseBodyMod_MultiArtifactMultiPartFailsClosed(t *testing.T) { + // Text parts spread across multiple artifacts still triggers the guard. + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"first"}]},{"parts":[{"kind":"text","text":"second"}]}]}}`), + } + mutated, err := applyA2AResponseBodyMod(pctx, "redacted") + if err == nil { + t.Fatal("expected fail-closed error for multi-artifact text parts, got nil") + } + if mutated { + t.Fatal("mutated=true on multi-artifact fail-closed") + } +} + func TestA2AResponseBodyMod_StreamingFailsClosed(t *testing.T) { pctx := &pipeline.Context{ ResponseBody: []byte("data: {\"result\":{}}\n\n"), diff --git a/authbridge/authlib/plugins/cpex/cmf_body.go b/authbridge/authlib/plugins/cpex/cmf_body.go index 4275ee991..3fe40e755 100644 --- a/authbridge/authlib/plugins/cpex/cmf_body.go +++ b/authbridge/authlib/plugins/cpex/cmf_body.go @@ -10,6 +10,17 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) +// isStreamingResponseGap reports whether a response-phase invocation has a +// non-empty body that yielded zero structured content parts — i.e. a +// streaming (SSE) body the policy engine can't inspect. Callers use this +// to fail closed rather than silently allowing unredacted content through. +func isStreamingResponseGap(pctx *pipeline.Context, isResponse bool, cmfPartCount int) bool { + if !isResponse || cmfPartCount > 0 || len(pctx.ResponseBody) == 0 { + return false + } + return pctx.Extensions.MCP != nil || pctx.Extensions.Inference != nil || pctx.Extensions.A2A != nil +} + // sessionIDFromHeaders returns the X-Session-Id request header, the // session-correlation key AuthBridge threads into CPEX's session resolver // (tier-0 Agent.SessionID) for non-A2A traffic. MCP and inference requests diff --git a/authbridge/authlib/plugins/cpex/cmf_body_test.go b/authbridge/authlib/plugins/cpex/cmf_body_test.go index 5683f803c..4d6f83708 100644 --- a/authbridge/authlib/plugins/cpex/cmf_body_test.go +++ b/authbridge/authlib/plugins/cpex/cmf_body_test.go @@ -573,3 +573,74 @@ func TestSessionIDFromHeaders(t *testing.T) { t.Fatalf("got %q, want taint-demo-42", got) } } + +// --- isStreamingResponseGap --- + +func TestIsStreamingResponseGap_SSEBodyWithProtocolExtension(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte("data: {\"result\":{}}\n\n"), + Extensions: pipeline.Extensions{ + A2A: &pipeline.A2AExtension{Method: "message/stream"}, + }, + } + if !isStreamingResponseGap(pctx, true, 0) { + t.Fatal("expected streaming gap: SSE body, A2A extension, zero parts") + } +} + +func TestIsStreamingResponseGap_InferenceSSE(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte("data: {\"choices\":[{\"delta\":{}}]}\n\n"), + Extensions: pipeline.Extensions{ + Inference: &pipeline.InferenceExtension{Model: "gpt-4"}, + }, + } + if !isStreamingResponseGap(pctx, true, 0) { + t.Fatal("expected streaming gap for inference SSE") + } +} + +func TestIsStreamingResponseGap_NotOnRequestPhase(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte("data: something\n\n"), + Extensions: pipeline.Extensions{ + A2A: &pipeline.A2AExtension{Method: "message/stream"}, + }, + } + if isStreamingResponseGap(pctx, false, 0) { + t.Fatal("request phase should never report a streaming gap") + } +} + +func TestIsStreamingResponseGap_PartsPresent(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"ok"}]}]}}`), + Extensions: pipeline.Extensions{ + A2A: &pipeline.A2AExtension{Method: "message/send"}, + }, + } + if isStreamingResponseGap(pctx, true, 1) { + t.Fatal("should not report gap when parts were successfully extracted") + } +} + +func TestIsStreamingResponseGap_EmptyBodyNoGap(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: nil, + Extensions: pipeline.Extensions{ + A2A: &pipeline.A2AExtension{Method: "message/send"}, + }, + } + if isStreamingResponseGap(pctx, true, 0) { + t.Fatal("empty body should not be a streaming gap") + } +} + +func TestIsStreamingResponseGap_NoProtocolExtension(t *testing.T) { + pctx := &pipeline.Context{ + ResponseBody: []byte("data: something\n\n"), + } + if isStreamingResponseGap(pctx, true, 0) { + t.Fatal("no protocol extension → no gap (unrecognised traffic)") + } +} diff --git a/authbridge/authlib/plugins/cpex/manager.go b/authbridge/authlib/plugins/cpex/manager.go index 08b8a6517..46d7c3108 100644 --- a/authbridge/authlib/plugins/cpex/manager.go +++ b/authbridge/authlib/plugins/cpex/manager.go @@ -12,7 +12,7 @@ import ( // // The real implementation lives in manager_cpex.go (//go:build cpex) // and wraps the cpex.PluginManager from the -// github.com/IBM/contextforge-plugins-framework/go/cpex package. The +// github.com/contextforge-org/cpex/go/cpex package. The // stub in manager_stub.go (//go:build !cpex) makes NewManager error // out with a clear "build the cpex binary" message if anyone tries to // configure the plugin in a binary that wasn't built with -tags cpex. @@ -88,9 +88,15 @@ type ManagerOptions struct { type Decision int const ( + // DecisionUnknown is the zero-value sentinel. A Result whose + // Decision was never explicitly set fails closed in applyDecision + // (the default arm), so an uninitialised Result never silently + // allows traffic. + DecisionUnknown Decision = iota + // DecisionAllow: all sub-plugins continued. Request proceeds // untouched. - DecisionAllow Decision = iota + DecisionAllow // DecisionDeny: at least one sub-plugin returned a policy // violation. Plugin emits pipeline.Deny. @@ -113,6 +119,8 @@ const ( // reasons and log fields. func (d Decision) String() string { switch d { + case DecisionUnknown: + return "unknown" case DecisionAllow: return "allow" case DecisionDeny: diff --git a/authbridge/authlib/plugins/cpex/manager_cpex.go b/authbridge/authlib/plugins/cpex/manager_cpex.go index 1bab0d99a..896bd88d0 100644 --- a/authbridge/authlib/plugins/cpex/manager_cpex.go +++ b/authbridge/authlib/plugins/cpex/manager_cpex.go @@ -114,6 +114,18 @@ func (c *cpexManager) Invoke(_ context.Context, hookName string, pctx *pipeline. isResponse := pctx.CurrentPhase() == pipeline.InvocationPhaseResponse payload, ext := buildCMF(pctx, isResponse) + // Streaming-response guard: when we're on the response phase, the + // body is non-empty, a protocol parser claimed this traffic, yet the + // CMF message has zero content parts — the body is an SSE stream + // that the policy engine can't inspect. Rather than silently allowing + // the unredacted stream through (the policy had nothing to evaluate), + // surface a DecisionError so the fail_open knob governs: the default + // fail-closed denies, fail_open=true logs and allows. + if isStreamingResponseGap(pctx, isResponse, len(payload.Message.Content)) { + return Result{Decision: DecisionError, Reason: "streaming response body not inspectable by policy"}, + fmt.Errorf("cpex: response body present (%d bytes) but yielded zero content parts — likely SSE stream; failing closed", len(pctx.ResponseBody)) + } + // Fused identity-resolve + hook invoke. cpex-core runs the identity // resolvers (jwt-user / jwt-client) ONLY on the identity.resolve hook, // never inside a tool/prompt/resource hook. An FFI host must therefore @@ -265,8 +277,8 @@ func awaitBackground(hook, reqID string, bg *rcpex.BackgroundTasks) { } // applyModificationsToPctx writes CPEX's modified Extensions and body -// back onto pctx. MCP body modifications are re-serialized; inference / -// A2A body rewriting is not yet implemented and fails closed (see +// back onto pctx. MCP, inference, and A2A body modifications are each +// re-serialized via format-aware write-back functions (see // applyBodyModFromCMF). // // Extension changes applied: diff --git a/authbridge/authlib/plugins/cpex/manager_test.go b/authbridge/authlib/plugins/cpex/manager_test.go index a9bbd5464..df5d7b891 100644 --- a/authbridge/authlib/plugins/cpex/manager_test.go +++ b/authbridge/authlib/plugins/cpex/manager_test.go @@ -25,7 +25,7 @@ type FakeManager struct { // Hooks maps hook name → canned Result. Invoke returns the // canned Result when the hook name matches; otherwise it - // returns DecisionAllow with a "no hook configured" reason. + // returns DecisionDeny (fail-closed default for test fidelity). Hooks map[string]Result // InvokeErr, if non-nil, is returned from every Invoke before @@ -107,7 +107,10 @@ func (f *FakeManager) HasHook(name string) bool { } // Invoke records the call and returns InvokeErr (if set), the canned -// Result for hookName (if in Hooks), or a default DecisionAllow. +// Result for hookName (if in Hooks), or a default DecisionDeny. The +// deny default mirrors production fail-closed semantics: tests must +// explicitly wire Hooks with Allow/Modify results for the paths they +// exercise, so an unconfigured hook is never silently permitted. func (f *FakeManager) Invoke(_ context.Context, hookName string, pctx *pipeline.Context) (Result, error) { f.mu.Lock() defer f.mu.Unlock() @@ -118,5 +121,5 @@ func (f *FakeManager) Invoke(_ context.Context, hookName string, pctx *pipeline. if r, ok := f.Hooks[hookName]; ok { return r, nil } - return Result{Decision: DecisionAllow, Reason: "fake: no hook configured"}, nil + return Result{Decision: DecisionDeny, Reason: "fake: no hook configured (deny by default)"}, nil } diff --git a/authbridge/authlib/plugins/cpex/plugin_test.go b/authbridge/authlib/plugins/cpex/plugin_test.go index b0e647980..767ba796e 100644 --- a/authbridge/authlib/plugins/cpex/plugin_test.go +++ b/authbridge/authlib/plugins/cpex/plugin_test.go @@ -529,6 +529,26 @@ func TestDispatch_ErrorDecisionFailOpen(t *testing.T) { } } +func TestDispatch_UnknownDecisionFailsClosed(t *testing.T) { + // A Decision value the switch doesn't recognise (including the + // zero-value DecisionUnknown) must fail closed via the default arm. + fake := &FakeManager{ + KnownHooks: []string{HookToolPreInvoke}, + Hooks: map[string]Result{ + HookToolPreInvoke: {Decision: DecisionUnknown, Reason: "zero-value sentinel"}, + }, + } + cfg := `{"hooks":{"on_request":["cmf.tool_pre_invoke"]},"fail_open":false}` + p := setupAndInit(t, fake, cfg) + a := p.OnRequest(context.Background(), &pipeline.Context{}) + if a.Type != pipeline.Reject { + t.Fatalf("Type = %d, want Reject (DecisionUnknown must fail closed)", a.Type) + } + if a.Violation == nil || a.Violation.Code != "cpex.error" { + t.Fatalf("want Violation code=cpex.error, got %#v", a.Violation) + } +} + func TestSanitizeReason(t *testing.T) { cases := []struct { in string diff --git a/authbridge/cmd/authbridge-cpex/Dockerfile b/authbridge/cmd/authbridge-cpex/Dockerfile index f94252bf4..4e4cd1c5c 100644 --- a/authbridge/cmd/authbridge-cpex/Dockerfile +++ b/authbridge/cmd/authbridge-cpex/Dockerfile @@ -28,13 +28,11 @@ # ./FFI_ABI # ./LICENSE # -# This stage fetches the tarball + its .sha256 sidecar, verifies the -# checksum, extracts the .a. Cosign verification is a follow-up (we -# have the .sig/.crt to wire it up); for now the sha256 is the -# integrity check. +# This stage fetches the tarball + its .sha256 + cosign signature, +# verifies the checksum AND the cosign signature, then extracts the .a. FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d AS ffi -RUN apk add --no-cache curl ca-certificates tar +RUN apk add --no-cache curl ca-certificates tar cosign ARG CPEX_FFI_VERSION ARG TARGETARCH @@ -44,14 +42,18 @@ ARG TARGETOS=linux ARG CPEX_LIBC=gnu ARG FFI_ASSET=cpex-ffi-${CPEX_FFI_VERSION}-${TARGETOS}-${TARGETARCH}-${CPEX_LIBC}.tar.gz +ARG CPEX_COSIGN_IDENTITY=https://github.com/contextforge-org/cpex/.github/workflows/release-ffi.yaml@refs/tags/${CPEX_FFI_VERSION} +ARG CPEX_COSIGN_ISSUER=https://token.actions.githubusercontent.com + WORKDIR /ffi RUN set -eu; \ BASE="https://github.com/contextforge-org/cpex/releases/download/${CPEX_FFI_VERSION}"; \ echo "[ffi] downloading ${BASE}/${FFI_ASSET}"; \ curl -fsSLo asset.tar.gz "${BASE}/${FFI_ASSET}"; \ curl -fsSLo asset.tar.gz.sha256 "${BASE}/${FFI_ASSET}.sha256"; \ - # sha256 file format: " " — rename our local copy \ - # so the line matches, then verify. \ + curl -fsSLo asset.tar.gz.sig "${BASE}/${FFI_ASSET}.sig"; \ + curl -fsSLo asset.tar.gz.crt "${BASE}/${FFI_ASSET}.crt"; \ + # SHA-256 integrity check. \ expected_hash=$(awk '{print $1}' asset.tar.gz.sha256); \ actual_hash=$(sha256sum asset.tar.gz | awk '{print $1}'); \ if [ "$expected_hash" != "$actual_hash" ]; then \ @@ -59,6 +61,16 @@ RUN set -eu; \ exit 1; \ fi; \ echo "[ffi] sha256 verified"; \ + # Cosign signature verification — proves the asset was produced by \ + # the cpex release workflow and hasn't been tampered with. \ + cosign verify-blob \ + --signature asset.tar.gz.sig \ + --certificate asset.tar.gz.crt \ + --certificate-identity "${CPEX_COSIGN_IDENTITY}" \ + --certificate-oidc-issuer "${CPEX_COSIGN_ISSUER}" \ + asset.tar.gz \ + && echo "[ffi] cosign signature verified" \ + || { echo "[ffi] cosign verification FAILED" >&2; exit 1; }; \ tar -xzf asset.tar.gz; \ test -f libcpex_ffi.a || { echo "[ffi] libcpex_ffi.a missing from tarball" >&2; exit 1; }; \ echo "[ffi] extracted libcpex_ffi.a + VERSION=$(cat VERSION) + FFI_ABI=$(cat FFI_ABI)" diff --git a/authbridge/demos/hr-cpex/k8s/30-agent.yaml b/authbridge/demos/hr-cpex/k8s/30-agent.yaml index b77988088..8b27c847c 100644 --- a/authbridge/demos/hr-cpex/k8s/30-agent.yaml +++ b/authbridge/demos/hr-cpex/k8s/30-agent.yaml @@ -150,7 +150,7 @@ spec: # Desktop the host is reached at http://192.168.5.2:11434 # (lima host-gateway) and Ollama must bind 0.0.0.0. - name: MODEL - value: "ollama/llama3:latest" + value: "ollama/llama3.2:8b" - name: LLM_API_BASE value: "http://host.docker.internal:11434" - name: AGENT_PUBLIC_URL diff --git a/authbridge/demos/hr-cpex/k8s/realm-export.json b/authbridge/demos/hr-cpex/k8s/realm-export.json index 71367bc1f..dcb933ad3 100644 --- a/authbridge/demos/hr-cpex/k8s/realm-export.json +++ b/authbridge/demos/hr-cpex/k8s/realm-export.json @@ -1,4 +1,5 @@ { + "_WARNING": "DEMO ONLY — passwords and client secrets in this file are for local development. Do not use in production.", "realm": "cpex-demo", "enabled": true, "displayName": "CPEX Demo Realm", diff --git a/authbridge/docs/cpex-plugin.md b/authbridge/docs/cpex-plugin.md index e318b9705..06ae91b2a 100644 --- a/authbridge/docs/cpex-plugin.md +++ b/authbridge/docs/cpex-plugin.md @@ -1,5 +1,9 @@ # cpex plugin +> **`authbridge-cpex` is a build variant** of the AuthBridge proxy-sidecar +> (`authbridge-proxy`), deployed *in place of* `authbridge-proxy` when +> CPEX policy enforcement is needed — not an additional sidecar. + The `cpex` plugin embeds the [CPEX](https://github.com/contextforge-org/cpex) runtime inside AuthBridge so operators can drive policy with CPEX's APL (Attribute Policy Language) DSL — and any of the pre-built CPEX From 7da57be29f9a3485fcb79b8245aae44433cb387b Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 12 Jun 2026 21:26:06 -0400 Subject: [PATCH 10/18] =?UTF-8?q?fix(cpex):=20address=20PR=20#493=20re-rev?= =?UTF-8?q?iew=20=E2=80=94=20symmetric=20MCP=20response=20guard=20+=20hard?= =?UTF-8?q?ening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the carried-over blocker and remaining suggestions from Hai Huang's re-review: Must-fix (blocker, carried over): - applyMCPResponseBodyMod (cmf_body.go): the MCP response path had the identical multi-part leak the A2A path had — it rewrote only the first result.content[] text block and break'd, forwarding blocks[1..] verbatim with mutated=true. Now collects all text blocks and fails closed on >1 (the read side only inspects the first; one redacted value can't safely rewrite many), mirroring the A2A/inference guards. Added a multi-block fail-closed test asserting the planted secret is not forwarded. Suggestions: - MCPRequestBodyMod: replace the len(NewArguments)==0 no-op inference with explicit ArgsSet/URISet flags. A "strip all arguments" redaction returns an empty-but-set map and must apply, not silently forward the original args. Added strip-all-applies + unset-no-op tests. - Docs: fix the misleading "Authorization stripped" comments in manager_cpex.go and headers.go — Authorization is deliberately KEPT for CPEX identity plugins. Made the audit-log redaction + session-API binding guidance a non-optional MUST. - Supply chain: assert the FFI tarball VERSION matches CPEX_FFI_VERSION and FFI_ABI matches the new pinned CPEX_FFI_ABI file at build time (dropped the stale TODO). Wired CPEX_FFI_ABI through the demo Makefile and CI build matrix. Runtime ABI assertion already exists in the Go binding's abi.go init(). Verified: go test ./plugins/cpex/ passes; go vet clean; authbridge-cpex image rebuilds with sha256 + cosign + VERSION/ABI all verified; hr-cpex demo 9/9 scenarios pass on kind (Bob SSN visible, Eve redacted, denies enforce, taint + cross-principal isolation intact). Also retitled the PR to "Feat:" to satisfy the org verify-pr-title gate. Signed-off-by: Frederico Araujo --- .github/workflows/build.yaml | 11 +-- authbridge/authlib/plugins/cpex/cmf_body.go | 50 +++++++++++-- .../authlib/plugins/cpex/cmf_body_test.go | 72 ++++++++++++++++--- authbridge/authlib/plugins/cpex/headers.go | 16 +++-- .../authlib/plugins/cpex/manager_cpex.go | 16 ++++- authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI | 1 + authbridge/cmd/authbridge-cpex/Dockerfile | 35 +++++++-- authbridge/demos/hr-cpex/Makefile | 1 + 8 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index eb6c560ef..771d261f0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -116,18 +116,21 @@ jobs: # Add 'latest' tag for version tags, workflow_dispatch, and pushes to main type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }} - # 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION, - # the single source of truth for the FFI ABI this image links - # (read from the file next to its Dockerfile). Other images leave - # this empty (an undeclared build-arg is ignored by their builds). + # 6b. Resolve build-args. authbridge-cpex needs CPEX_FFI_VERSION + # (the release tag) and CPEX_FFI_ABI (the FFI ABI integer the + # linked lib must report) — both read from the files next to its + # Dockerfile and asserted against the tarball at build time. Other + # images leave this empty (an undeclared build-arg is ignored). - name: Resolve build args id: buildargs run: | if [[ "${{ matrix.image_config.name }}" == "authbridge-cpex" ]]; then VERSION="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION)" + ABI="$(tr -d '[:space:]' < authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI)" { echo "args<> "$GITHUB_OUTPUT" else diff --git a/authbridge/authlib/plugins/cpex/cmf_body.go b/authbridge/authlib/plugins/cpex/cmf_body.go index 3fe40e755..34987b732 100644 --- a/authbridge/authlib/plugins/cpex/cmf_body.go +++ b/authbridge/authlib/plugins/cpex/cmf_body.go @@ -444,12 +444,23 @@ func stringifyRPCID(id any) string { // prompts/get → NewArguments → params.arguments // resources/read → NewURI → params.uri // +// ArgsSet / URISet are the explicit "this field carries a real +// modification" signals. We do NOT infer intent from emptiness: a +// policy whose redaction is "strip all arguments" legitimately returns +// an empty (or nil) arguments map, and a "blank the URI" redaction +// returns an empty URI. Treating empty-as-no-op would silently forward +// the ORIGINAL body (secret intact) while the policy believed it had +// redacted. The cgo adapter sets these flags when the corresponding +// CMF content part is present, regardless of value. +// // Other methods are no-ops — the operator's APL policy can't rewrite // protocol mechanics (initialize, tools/list, etc.) because there's // no semantically meaningful target for the rewrite. type MCPRequestBodyMod struct { NewArguments map[string]any + ArgsSet bool NewURI string + URISet bool } // applyMCPRequestBodyMod rewrites pctx.Body, which is expected to be @@ -473,12 +484,16 @@ func applyMCPRequestBodyMod(pctx *pipeline.Context, method string, mod MCPReques switch method { case "tools/call", "prompts/get": - if len(mod.NewArguments) == 0 { + // Gate on the explicit ArgsSet flag, NOT len()==0: a policy that + // stripped all arguments returns an empty map and still means to + // rewrite. Inferring no-op from emptiness would forward the + // original (un-redacted) arguments. + if !mod.ArgsSet { return false, nil } params["arguments"] = mod.NewArguments case "resources/read": - if mod.NewURI == "" { + if !mod.URISet { return false, nil } params["uri"] = mod.NewURI @@ -503,6 +518,17 @@ func applyMCPRequestBodyMod(pctx *pipeline.Context, method string, mod MCPReques // Don't introduce structuredContent on a response that didn't carry // it; clients sniffing for the new shape would be surprised. // +// Fail-closed on multiple text blocks: the read side +// (extractToolResultContent) surfaces only the FIRST text block to the +// policy, and we hold a single redacted value. A result.content[] with +// >1 text block is therefore an ambiguous rewrite — blocks[1..] were +// never shown to the policy, so rewriting only block[0] would forward +// the others (potentially carrying the same secret) verbatim while +// reporting success. We return an error so the caller fails closed +// rather than leaking the un-inspected blocks (mirrors the A2A and +// inference response guards). Rewrite all blocks would be wrong too — +// we'd stamp one value over distinct blocks. +// // Returns mutated=true when SetResponseBody was called; mutated=false // when the body wasn't a tools/call response, had no result.content, // or the response had no replaceable text block. @@ -524,20 +550,30 @@ func applyMCPResponseBodyMod(pctx *pipeline.Context, method string, newContent a replaced := false - // Primary path: rewrite the first text block in result.content[]. - // This is what every MCP client we see today reads from. + // Primary path: rewrite the text block in result.content[]. Collect + // all text blocks first so >1 fails closed rather than silently + // leaving blocks[1..] unredacted (the read side only inspected the + // first; we only hold one redacted value). if contentArr, ok := result["content"].([]any); ok { + textBlocks := make([]map[string]any, 0, len(contentArr)) for _, block := range contentArr { blockObj, ok := block.(map[string]any) if !ok { continue } if t, ok := blockObj["type"].(string); ok && t == "text" { - blockObj["text"] = stringifyForTextBlock(newContent) - replaced = true - break + textBlocks = append(textBlocks, blockObj) } } + if len(textBlocks) > 1 { + return false, fmt.Errorf( + "MCP tools/call response has %d text blocks; single-value redaction rewrite is ambiguous", + len(textBlocks)) + } + if len(textBlocks) == 1 { + textBlocks[0]["text"] = stringifyForTextBlock(newContent) + replaced = true + } } // Secondary path: update structuredContent when it was already diff --git a/authbridge/authlib/plugins/cpex/cmf_body_test.go b/authbridge/authlib/plugins/cpex/cmf_body_test.go index 4d6f83708..111e4f2b7 100644 --- a/authbridge/authlib/plugins/cpex/cmf_body_test.go +++ b/authbridge/authlib/plugins/cpex/cmf_body_test.go @@ -16,7 +16,7 @@ func TestMCPRequestBodyMod_ToolsCallArgsReplaced(t *testing.T) { Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_compensation","arguments":{"employee_id":"E001","include_ssn":true}}}`), } newArgs := map[string]any{"employee_id": "E001"} // include_ssn redacted - mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: newArgs}) + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: newArgs, ArgsSet: true}) if err != nil { t.Fatalf("err: %v", err) } @@ -41,7 +41,7 @@ func TestMCPRequestBodyMod_PromptsGetArgsReplaced(t *testing.T) { Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"weather","arguments":{"city":"SF"}}}`), } newArgs := map[string]any{"city": "REDACTED"} - mutated, err := applyMCPRequestBodyMod(pctx, "prompts/get", MCPRequestBodyMod{NewArguments: newArgs}) + mutated, err := applyMCPRequestBodyMod(pctx, "prompts/get", MCPRequestBodyMod{NewArguments: newArgs, ArgsSet: true}) if err != nil || !mutated { t.Fatalf("mutated=%v err=%v", mutated, err) } @@ -54,7 +54,7 @@ func TestMCPRequestBodyMod_ResourcesReadURIReplaced(t *testing.T) { pctx := &pipeline.Context{ Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///secret"}}`), } - mutated, err := applyMCPRequestBodyMod(pctx, "resources/read", MCPRequestBodyMod{NewURI: "file:///public"}) + mutated, err := applyMCPRequestBodyMod(pctx, "resources/read", MCPRequestBodyMod{NewURI: "file:///public", URISet: true}) if err != nil || !mutated { t.Fatalf("mutated=%v err=%v", mutated, err) } @@ -63,7 +63,8 @@ func TestMCPRequestBodyMod_ResourcesReadURIReplaced(t *testing.T) { } } -func TestMCPRequestBodyMod_EmptyArgsNoOp(t *testing.T) { +func TestMCPRequestBodyMod_UnsetArgsNoOp(t *testing.T) { + // No ArgsSet flag (e.g. CPEX returned no tool_call part) → no-op. orig := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"a":1}}}`) pctx := &pipeline.Context{Body: append([]byte(nil), orig...)} mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{}) @@ -71,18 +72,46 @@ func TestMCPRequestBodyMod_EmptyArgsNoOp(t *testing.T) { t.Fatalf("err: %v", err) } if mutated { - t.Fatal("expected mutated=false on empty mod") + t.Fatal("expected mutated=false when ArgsSet is false") } if string(pctx.Body) != string(orig) { t.Fatalf("body changed despite no-op: %s", pctx.Body) } } +func TestMCPRequestBodyMod_StripAllArgsApplies(t *testing.T) { + // A "strip all arguments" redaction returns an empty args map WITH + // ArgsSet=true. This MUST apply (clear params.arguments), not no-op: + // inferring no-op from emptiness would forward the original secret. + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"ssn":"123-45-6789"}}}`), + } + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", + MCPRequestBodyMod{NewArguments: map[string]any{}, ArgsSet: true}) + if err != nil { + t.Fatalf("err: %v", err) + } + if !mutated { + t.Fatal("expected mutated=true: empty-but-set args is a real redaction") + } + if strings.Contains(string(pctx.Body), "123-45-6789") { + t.Fatalf("original secret args forwarded despite strip-all redaction: %s", pctx.Body) + } + var decoded map[string]any + if err := json.Unmarshal(pctx.Body, &decoded); err != nil { + t.Fatalf("re-decode: %v", err) + } + args := decoded["params"].(map[string]any)["arguments"].(map[string]any) + if len(args) != 0 { + t.Fatalf("arguments should be empty after strip-all: %v", args) + } +} + func TestMCPRequestBodyMod_UnsupportedMethodNoOp(t *testing.T) { pctx := &pipeline.Context{ Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), } - mutated, err := applyMCPRequestBodyMod(pctx, "initialize", MCPRequestBodyMod{NewArguments: map[string]any{"x": 1}}) + mutated, err := applyMCPRequestBodyMod(pctx, "initialize", MCPRequestBodyMod{NewArguments: map[string]any{"x": 1}, ArgsSet: true}) if err != nil { t.Fatalf("err: %v", err) } @@ -93,7 +122,7 @@ func TestMCPRequestBodyMod_UnsupportedMethodNoOp(t *testing.T) { func TestMCPRequestBodyMod_MalformedJSONError(t *testing.T) { pctx := &pipeline.Context{Body: []byte(`{not json`)} - _, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}}) + _, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}, ArgsSet: true}) if err == nil { t.Fatal("expected error on malformed JSON") } @@ -101,7 +130,7 @@ func TestMCPRequestBodyMod_MalformedJSONError(t *testing.T) { func TestMCPRequestBodyMod_EmptyBodyNoOp(t *testing.T) { pctx := &pipeline.Context{} - mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}}) + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}, ArgsSet: true}) if err != nil || mutated { t.Fatalf("mutated=%v err=%v on empty body", mutated, err) } @@ -111,7 +140,7 @@ func TestMCPRequestBodyMod_NoParamsObjectNoOp(t *testing.T) { pctx := &pipeline.Context{ Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`), } - mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}}) + mutated, err := applyMCPRequestBodyMod(pctx, "tools/call", MCPRequestBodyMod{NewArguments: map[string]any{"a": 1}, ArgsSet: true}) if err != nil || mutated { t.Fatalf("mutated=%v err=%v on body without params", mutated, err) } @@ -146,6 +175,31 @@ func TestMCPResponseBodyMod_TextBlockReplaced(t *testing.T) { } } +func TestMCPResponseBodyMod_MultiTextBlockFailsClosed(t *testing.T) { + // A tools/call result with ≥2 text blocks is an ambiguous rewrite: + // the read side (extractToolResultContent) only surfaced the first + // block to the policy, and we hold one redacted value. Rewriting only + // block[0] would forward block[1] (here carrying the same secret) + // verbatim. Must fail closed. + pctx := &pipeline.Context{ + ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"first block ssn:123-45-6789"},{"type":"text","text":"second block ssn:123-45-6789"}]}}`), + } + newContent := map[string]any{"name": "Jane Smith"} // SSN removed + mutated, err := applyMCPResponseBodyMod(pctx, "tools/call", newContent) + if err == nil { + t.Fatal("expected fail-closed error for multi-text-block response, got nil") + } + if mutated { + t.Fatal("mutated=true on multi-block fail-closed") + } + // The body must be untouched — and critically, the planted secret in + // the un-inspected blocks must NOT have been forwarded as a "success". + // (We assert via the returned error contract; the body stays original.) + if !strings.Contains(string(pctx.ResponseBody), "second block ssn:123-45-6789") { + t.Fatalf("response body unexpectedly altered: %s", pctx.ResponseBody) + } +} + func TestMCPResponseBodyMod_StructuredContentUpdated(t *testing.T) { pctx := &pipeline.Context{ ResponseBody: []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"x"}],"structuredContent":{"ssn":"123"}}}`), diff --git a/authbridge/authlib/plugins/cpex/headers.go b/authbridge/authlib/plugins/cpex/headers.go index 42a580a47..52702ff6e 100644 --- a/authbridge/authlib/plugins/cpex/headers.go +++ b/authbridge/authlib/plugins/cpex/headers.go @@ -42,9 +42,12 @@ import ( // which silently allows traffic the policy meant to deny. // // The audit-log risk this opens — bearer tokens reaching audit -// payloads — is mitigated by configuring audit-log to drop -// Authorization from its output (or by terminating TLS at the -// sidecar so tokens are short-lived and bound to mTLS). +// payloads and the unauthenticated session API — is NOT optional to +// mitigate: any audit-log sub-plugin MUST be configured to drop the +// Authorization header from its output, and the session API MUST stay +// bound to in-cluster addresses only (never behind ingress). Pairing +// with sidecar TLS termination (short-lived, mTLS-bound tokens) is a +// further hardening, not a substitute. // // This table and the helpers below are tag-free (no //go:build cpex) // so the default CGO_ENABLED=0 test build exercises them; the cgo @@ -77,9 +80,10 @@ var secretHeaderExact = map[string]struct{}{ // follow §3.2.2, lands in secretHeaderPrefixes and is stripped before // reaching here.) // -// Sensitive headers (Authorization, Cookie, X-Api-Key, …) are -// dropped via secretHeaderPrefixes / secretHeaderExact, NOT silently -// truncated. +// Sensitive headers (Cookie, X-Api-Key, …) are dropped via +// secretHeaderPrefixes / secretHeaderExact, NOT silently truncated. +// Authorization is the deliberate exception — see the NOTE above: it +// is kept so CPEX identity plugins can read the bearer. func flattenHeaders(h http.Header) map[string]string { if len(h) == 0 { return nil diff --git a/authbridge/authlib/plugins/cpex/manager_cpex.go b/authbridge/authlib/plugins/cpex/manager_cpex.go index 896bd88d0..6be1e1acd 100644 --- a/authbridge/authlib/plugins/cpex/manager_cpex.go +++ b/authbridge/authlib/plugins/cpex/manager_cpex.go @@ -415,15 +415,21 @@ func applyMCPBodyModFromCMF(pctx *pipeline.Context, msg *rcpex.Message, isRespon switch part.ContentType { case "tool_call": if part.ToolCallContent != nil { + // Set the flag whenever the part is present, even for + // an empty/nil args map: that's a deliberate "strip all + // arguments" redaction, not a no-op. mod.NewArguments = part.ToolCallContent.Arguments + mod.ArgsSet = true } case "prompt_request": if part.PromptRequestContent != nil { mod.NewArguments = part.PromptRequestContent.Arguments + mod.ArgsSet = true } case "resource_ref": if part.ResourceRefContent != nil { mod.NewURI = part.ResourceRefContent.URI + mod.URISet = true } default: continue @@ -508,9 +514,13 @@ func applyExtensionChanges(pctx *pipeline.Context, ext *rcpex.Extensions) { // ProvenanceExtension.MessageID // delegation → DelegationExtension (chain/origin/actor/depth) // request → RequestExtension (request id + trace/span ids) -// headers → HttpExtension.RequestHeaders (Authorization and Cookie -// stripped — they don't belong in policy context and would -// leak through CPEX traces) +// headers → HttpExtension.RequestHeaders (Cookie and other +// secret headers stripped; see flattenHeaders. NOTE: +// Authorization is deliberately KEPT so CPEX's identity +// plugins can read the bearer — it therefore reaches the +// RequestHeaders map and the unauthenticated session API. +// Audit-log sub-plugins MUST be configured to drop +// Authorization from their output. See headers.go.) // // Note (agent slot): the caller's client_id is NOT placed on // AgentExtension.AgentID. Caller identity lives in SecurityExtension.Subject; diff --git a/authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI b/authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI new file mode 100644 index 000000000..0cfbf0888 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_ABI @@ -0,0 +1 @@ +2 diff --git a/authbridge/cmd/authbridge-cpex/Dockerfile b/authbridge/cmd/authbridge-cpex/Dockerfile index 4e4cd1c5c..e9592c0a7 100644 --- a/authbridge/cmd/authbridge-cpex/Dockerfile +++ b/authbridge/cmd/authbridge-cpex/Dockerfile @@ -11,10 +11,15 @@ # enable CPEX. # ---------- Stage 1: fetch libcpex_ffi.a from the pinned release ---- -# The CPEX_FFI_VERSION file at the root of this directory is the -# single source of truth for which CPEX FFI ABI this binary expects. -# A pre-commit hook (TODO follow-up) enforces it matches the go.mod -# pin for the cpex Go bindings. +# The CPEX_FFI_VERSION file at the root of this directory pins the +# release tag; CPEX_FFI_ABI pins the FFI ABI integer the linked lib must +# report. Both are asserted at build time below: the extracted tarball's +# VERSION must equal CPEX_FFI_VERSION and its FFI_ABI must equal +# CPEX_FFI_ABI, so a renamed/mismatched asset fails the build rather than +# linking a lib whose ABI the Go bindings don't expect. The bindings +# additionally assert the ABI at runtime (go/cpex/abi.go panics at init() +# if the linked lib's cpex_ffi_abi_version() != its expected value), so a +# drift is caught both at build and at boot. # # Asset naming on the cpex release page: # cpex-ffi-${VERSION}-${OS}-${ARCH}-${LIBC}.tar.gz (tarball) @@ -35,6 +40,10 @@ FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd1 RUN apk add --no-cache curl ca-certificates tar cosign ARG CPEX_FFI_VERSION +# CPEX_FFI_ABI pins the FFI ABI integer the linked lib must report. The +# Makefile sources it from the CPEX_FFI_ABI file (same dir as +# CPEX_FFI_VERSION); the extract step below asserts the tarball matches. +ARG CPEX_FFI_ABI ARG TARGETARCH ARG TARGETOS=linux # LIBC defaults to gnu (Debian/Ubuntu container hosts). Override to @@ -73,7 +82,23 @@ RUN set -eu; \ || { echo "[ffi] cosign verification FAILED" >&2; exit 1; }; \ tar -xzf asset.tar.gz; \ test -f libcpex_ffi.a || { echo "[ffi] libcpex_ffi.a missing from tarball" >&2; exit 1; }; \ - echo "[ffi] extracted libcpex_ffi.a + VERSION=$(cat VERSION) + FFI_ABI=$(cat FFI_ABI)" + # Assert the tarball's pinned identity matches what we expect. A \ + # mismatched VERSION means the asset was renamed/swapped; a mismatched \ + # FFI_ABI means the lib's C surface differs from what the Go bindings \ + # were generated against (a CGO mis-marshal waiting to happen). \ + # VERSION is a multi-line key=value manifest (version=, git_sha=, \ + # tuple=, …); pull the version= line. FFI_ABI is a bare integer. \ + got_version=$(grep -E '^version=' VERSION | head -1 | cut -d= -f2- | tr -d '[:space:]'); \ + if [ "$got_version" != "$CPEX_FFI_VERSION" ]; then \ + echo "[ffi] VERSION mismatch: tarball says '$got_version', expected '$CPEX_FFI_VERSION'" >&2; \ + exit 1; \ + fi; \ + got_abi=$(tr -d '[:space:]' < FFI_ABI); \ + if [ -n "$CPEX_FFI_ABI" ] && [ "$got_abi" != "$CPEX_FFI_ABI" ]; then \ + echo "[ffi] FFI_ABI mismatch: tarball says '$got_abi', expected '$CPEX_FFI_ABI' (bump CPEX_FFI_ABI + the Go binding)" >&2; \ + exit 1; \ + fi; \ + echo "[ffi] extracted libcpex_ffi.a + VERSION=$got_version + FFI_ABI=$got_abi (asserted)" # ---------- Stage 2: build authbridge-cpex with cgo + -tags cpex ---- # golang:bookworm (glibc) — alpine + musl would need extra ring/ diff --git a/authbridge/demos/hr-cpex/Makefile b/authbridge/demos/hr-cpex/Makefile index e08205fec..369538758 100644 --- a/authbridge/demos/hr-cpex/Makefile +++ b/authbridge/demos/hr-cpex/Makefile @@ -85,6 +85,7 @@ build-images: preflight ## Build authbridge-cpex + hr-mcp + agent docker images cd $(AUTHBRIDGE_ROOT) && docker build \ -f cmd/authbridge-cpex/Dockerfile \ --build-arg CPEX_FFI_VERSION=$$(cat cmd/authbridge-cpex/CPEX_FFI_VERSION | tr -d '[:space:]') \ + --build-arg CPEX_FFI_ABI=$$(cat cmd/authbridge-cpex/CPEX_FFI_ABI | tr -d '[:space:]') \ -t $(AUTHBRIDGE_IMAGE) . @echo "[build] hr-mcp from $(HR_MCP_SRC)" cd $(HR_MCP_SRC) && docker build -t $(HR_MCP_IMAGE) . From 634f6d6dc88c838046367ece06ecb8b9243e01e5 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 12 Jun 2026 22:05:25 -0400 Subject: [PATCH 11/18] docs(cpex): close remaining PR #493 review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final sweep to ensure every Hai Huang comment is addressed: - cpex-policy.yaml: add a DEMO ONLY banner — the file embeds hardcoded client secrets (client_secret_source.kind: literal) over plaintext http to a local Keycloak. Addresses the "kind: literal secrets want a demo-only banner" nit (previously only realm-export.json was banner'd). - cmf_a2a_test.go: TestA2AResponseBodyMod_RewritesArtifact now asserts the ORIGINAL secret is absent after redaction, not just that the redacted string is present (the reviewer's explicit ask — a partial rewrite would satisfy presence-only). - README.md: correct the OPA/PDP note — the demo DOES exercise the embedded `cedar` PDP (scenario 05 → cpex.cedar_default_deny); only the external `opa(...)` / `authzen(...)` steps are doc-level BYOP examples. Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/README.md | 5 +++-- authbridge/authlib/plugins/cpex/cmf_a2a_test.go | 5 +++++ authbridge/demos/hr-cpex/k8s/cpex-policy.yaml | 5 +++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/authbridge/authlib/plugins/cpex/README.md b/authbridge/authlib/plugins/cpex/README.md index 8598f6d32..28a6a7858 100644 --- a/authbridge/authlib/plugins/cpex/README.md +++ b/authbridge/authlib/plugins/cpex/README.md @@ -152,8 +152,9 @@ checks pass; their decisions feed the same `on_deny` / `on_allow` effect lists. > effects, session tainting, and ordered composition with other PDPs. > Use the native `opa` plugin for standalone binary OPA gates; use > CPEX's `opa(...)` when OPA participates in a multi-PDP orchestration -> flow. The `authzen(...)` and `cedar` steps are doc-level BYOP examples -> not exercised in this demo. +> flow. Note: the CPEX `opa(...)` and `authzen(...)` steps above are +> doc-level BYOP examples — the hr-cpex demo exercises only the embedded +> `cedar` PDP, not external OPA/AuthZEN. ## CMF / extension mapping diff --git a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go index 0be698789..0ec5e60c2 100644 --- a/authbridge/authlib/plugins/cpex/cmf_a2a_test.go +++ b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go @@ -120,6 +120,11 @@ func TestA2AResponseBodyMod_RewritesArtifact(t *testing.T) { if want := "leaked [REDACTED]"; !jsonContains(t, pctx.ResponseBody, want) { t.Fatalf("artifact not redacted in %s", pctx.ResponseBody) } + // Assert the original secret is GONE, not merely that the redacted + // string is present (a partial rewrite would satisfy the latter). + if contains(string(pctx.ResponseBody), "123-45-6789") { + t.Fatalf("original secret still present after redaction: %s", pctx.ResponseBody) + } } func TestA2AResponseBodyMod_MultiPartFailsClosed(t *testing.T) { diff --git a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml index dfe770f75..2453b993f 100644 --- a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml +++ b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml @@ -1,5 +1,10 @@ # CPEX runtime config for the HR demo. # +# DEMO ONLY — this file embeds hardcoded client secrets +# (client_secret_source.kind: literal) and points at a local Keycloak +# over plaintext http. Do NOT use in production: source secrets from a +# mounted file / Secret (kind: file) and use TLS to the IdP. +# # ── How to read this file ─────────────────────────────────────────── # The `routes:` block at the BOTTOM is the story: three tool routes, # one per demo act, each spelling out the authorization chain for that From 02953224e59ec20cd5994db1ef987187f2762d5c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sun, 14 Jun 2026 21:55:34 -0400 Subject: [PATCH 12/18] build(cpex): slim authbridge-cpex to musl/alpine on CPEX v0.2.0-alpha.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authbridge-cpex image was ~151 MB, dominated by the debian:trixie-slim runtime base needed for the glibc FFI asset — the FFI binary itself was a small part of the delta. Switch to the musl FFI asset + Alpine base and pin the CPEX release that carries the size-first profile and the cedar small-stack fix. Result: 151 MB -> ~42 MB (binary 38 MB -> 28 MB), full 9/9 hr-cpex scenarios green on the musl image. Dockerfile: - CPEX_LIBC defaults to musl; runtime base debian:trixie-slim -> alpine:3.20 (same digest the proxy/lite images pin); builder golang:bookworm -> golang:1.25-alpine + gcc/musl-dev for the CGO link. - Pin the multi-arch *index* digest for golang:1.25-alpine, not a single-arch sub-manifest: unlike the pure-Go images (CGO_ENABLED=0, which cross-compile), this links a native-arch libcpex_ffi.a, so the builder must run on the target arch. The amd64-only digest broke arm64 builds. - Install libgcc in the runtime stage — the FFI links cedar/Rust unwinder symbols (_Unwind_*) that glibc bases ship implicitly but Alpine does not. - CGO_LDFLAGS drops -ldl (musl folds it into libc). Dependencies: - CPEX_FFI_VERSION v0.2.0-alpha.1 -> v0.2.0-alpha.2 (FFI ABI unchanged, stays 2). - Go binding github.com/contextforge-org/cpex/go/cpex bumped to v0.2.0-alpha.2 in authlib and cmd/authbridge-cpex. Signed-off-by: Frederico Araujo --- authbridge/authlib/go.mod | 2 +- authbridge/authlib/go.sum | 2 + .../cmd/authbridge-cpex/CPEX_FFI_VERSION | 2 +- authbridge/cmd/authbridge-cpex/Dockerfile | 65 ++++++++++++------- authbridge/cmd/authbridge-cpex/go.mod | 2 +- authbridge/cmd/authbridge-cpex/go.sum | 2 + 6 files changed, 48 insertions(+), 27 deletions(-) diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index fd81b37da..2be3b99ec 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -3,7 +3,7 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib go 1.25.4 require ( - github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 + github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 github.com/envoyproxy/go-control-plane/envoy v1.37.0 github.com/fsnotify/fsnotify v1.10.1 github.com/gobwas/glob v0.2.3 diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index cea1eb11c..753c03e83 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -32,6 +32,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 h1:HWKqhOoh3dEscE8jWe0dYiOOegLW1liQmQ/RqBj7+Ks= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 h1:Ddpq+JFjnA7G4VUiHJPP2520GPmIv2XmikUV2n/gRd0= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION index cedb07d02..b68e0d37c 100644 --- a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION @@ -1 +1 @@ -v0.2.0-alpha.1 +v0.2.0-alpha.2 diff --git a/authbridge/cmd/authbridge-cpex/Dockerfile b/authbridge/cmd/authbridge-cpex/Dockerfile index e9592c0a7..e3e7e8523 100644 --- a/authbridge/cmd/authbridge-cpex/Dockerfile +++ b/authbridge/cmd/authbridge-cpex/Dockerfile @@ -46,9 +46,12 @@ ARG CPEX_FFI_VERSION ARG CPEX_FFI_ABI ARG TARGETARCH ARG TARGETOS=linux -# LIBC defaults to gnu (Debian/Ubuntu container hosts). Override to -# `musl` for Alpine-based deployments. -ARG CPEX_LIBC=gnu +# LIBC selects which CPEX FFI asset variant to link. We default to `musl` +# so the runtime stage can be Alpine (~8 MB) instead of debian:trixie-slim +# (~80-100 MB) — the base image, not the FFI binary, dominates this image's +# size. Override to `gnu` only for a glibc runtime (then also flip the +# builder/runtime bases below back to bookworm/debian). +ARG CPEX_LIBC=musl ARG FFI_ASSET=cpex-ffi-${CPEX_FFI_VERSION}-${TARGETOS}-${TARGETARCH}-${CPEX_LIBC}.tar.gz ARG CPEX_COSIGN_IDENTITY=https://github.com/contextforge-org/cpex/.github/workflows/release-ffi.yaml@refs/tags/${CPEX_FFI_VERSION} @@ -101,11 +104,17 @@ RUN set -eu; \ echo "[ffi] extracted libcpex_ffi.a + VERSION=$got_version + FFI_ABI=$got_abi (asserted)" # ---------- Stage 2: build authbridge-cpex with cgo + -tags cpex ---- -# golang:bookworm (glibc) — alpine + musl would need extra ring/ -# tokio cross-compile setup that the design doc defers to PR 2. -FROM golang:1.25-bookworm@sha256:bb979b278ffb8d31c8b07336fd187ef8fafc8766ebeaece524304483ea137e96 AS builder +# golang:alpine (musl) so we link the musl CPEX FFI asset and run on the +# small Alpine base. CGO needs a C compiler + musl headers (gcc, musl-dev). +# +# IMPORTANT: this pins the multi-arch *index* digest, not a single-arch +# sub-manifest. Unlike the pure-Go images (CGO_ENABLED=0, which cross-compile +# from any builder arch), this build links a native-arch libcpex_ffi.a, so the +# builder must run on the target arch. An amd64-only digest would make the +# arm64 build either fail to find an arm64 builder or link an incompatible .a. +FROM golang:1.25-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS builder -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache git ca-certificates gcc musl-dev WORKDIR /app @@ -116,27 +125,35 @@ COPY --from=ffi /ffi/libcpex_ffi.a /libs/libcpex_ffi.a ENV GOWORK=off ENV CGO_ENABLED=1 -ENV CGO_LDFLAGS="-L/libs -lcpex_ffi -lm -ldl" -# Let Go auto-download the toolchain version go.mod requires. The -# pinned base image carries 1.25.1; cpex bindings need 1.25.4 (and -# transitively bumps our cmd/authbridge-cpex/go.mod). GOTOOLCHAIN=auto -# means "download the version go.mod asks for" — keeps the base image -# pin stable across toolchain bumps. +# musl folds libdl/libpthread into libc, so there is no separate -ldl to +# link against (as there is on glibc). Keep -lm for the FFI's math symbols. +# +# No stack-size linker flag is needed: musl's small default thread stack +# (128 KB) used to trip cedar-policy's stacker recursion guard ("recursion +# limit reached"), but CPEX >= v0.2.0-alpha.2 wraps its cedar dispatch in +# stacker::maybe_grow, so the FFI grows its own stack regardless of the host. +ENV CGO_LDFLAGS="-L/libs -lcpex_ffi -lm" +# Let Go auto-download the toolchain version go.mod requires. The pinned +# base image carries 1.25.1; cpex bindings need 1.25.4 (and transitively +# bump our cmd/authbridge-cpex/go.mod). GOTOOLCHAIN=auto means "download +# the version go.mod asks for" — keeps the base image pin stable across +# toolchain bumps. ENV GOTOOLCHAIN=auto +# -trimpath drops absolute build paths for reproducibility; -s -w drop the +# Go symbol table + DWARF (panic traces still resolve via pclntab). RUN cd cmd/authbridge-cpex && \ - go build -tags cpex -ldflags="-s -w" -o /authbridge-cpex . + go build -tags cpex -trimpath -ldflags="-s -w" -o /authbridge-cpex . # ---------- Stage 3: runtime ---------------------------------------- -# debian:trixie (Debian 13) for GLIBC 2.39+ — the cpex release .a is -# built against that floor. bookworm (Debian 12, GLIBC 2.36) link-loads -# but fails at runtime with "GLIBC_2.39 not found". When we eventually -# move to a statically-linked CGO build (or the musl asset variant), -# we can downgrade this back to the smaller base. -FROM debian:trixie-slim@sha256:b6e2a152f22a40ff69d92cb397223c906017e1391a73c952b588e51af8883bf8 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - bash ca-certificates && \ - rm -rf /var/lib/apt/lists/* +# Alpine — same base + digest the authbridge-proxy / -lite images pin. The +# musl FFI asset links against musl, so no glibc floor and no debian base. +# entrypoint.sh is bash, so we install bash (matching the proxy image). +FROM alpine:3.20@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d + +# libgcc provides libgcc_s.so.1 — the C++/Rust stack unwinder the FFI links +# against (the CPEX .a is built panic=unwind, so it references _Unwind_*). +# glibc bases ship it implicitly; on Alpine it must be installed explicitly. +RUN apk add --no-cache bash ca-certificates libgcc COPY --from=builder --chmod=755 /authbridge-cpex /usr/local/bin/authbridge-cpex COPY --chmod=755 cmd/authbridge-cpex/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/authbridge/cmd/authbridge-cpex/go.mod b/authbridge/cmd/authbridge-cpex/go.mod index 434403715..39d42f5f2 100644 --- a/authbridge/cmd/authbridge-cpex/go.mod +++ b/authbridge/cmd/authbridge-cpex/go.mod @@ -6,7 +6,7 @@ require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101 require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 // indirect + github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/authbridge/cmd/authbridge-cpex/go.sum b/authbridge/cmd/authbridge-cpex/go.sum index c621fd2a1..46b1606ad 100644 --- a/authbridge/cmd/authbridge-cpex/go.sum +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -20,6 +20,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 h1:HWKqhOoh3dEscE8jWe0dYiOOegLW1liQmQ/RqBj7+Ks= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 h1:Ddpq+JFjnA7G4VUiHJPP2520GPmIv2XmikUV2n/gRd0= +github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= From a634b03f5b342cf2e6efc1a99f84d065c8eb74b8 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 16 Jun 2026 11:18:37 -0400 Subject: [PATCH 13/18] fix(cpex): address PR #493 review nits on the hr-cpex demo Hai's 2026-06-15 inline nits: - Demo secret sourcing: switch both CPEX delegators from client_secret_source.kind: literal to kind: file. Add a cpex-gateway-secret Secret mounted at /etc/cpex-secrets in the sidecar, so the gateway client secret is no longer inlined in the applied policy manifest. Models the production-shaped pattern for operators copying the example. Verified: all 9 hr-cpex scenarios still pass, including the Workday/GitHub delegation flows that read the secret from the mounted file. - Pin the demo agent base image (python:3.12-slim) to its multi-arch index digest, matching the SHA-pinned authbridge-cpex image rather than floating on the tag. - Drop the stale cpex v0.2.0-alpha.1 entries from authlib/go.sum and cmd/authbridge-cpex/go.sum (go.mod requires only alpha.2). Removed by hand rather than 'go mod tidy': the binding is imported only under //go:build cpex, so a default tidy would not see it and would drop the dependency. Also documents the internal-endpoint posture in the demo README: the session API (:9094) and stats (:9093) have no auth and are bound to 127.0.0.1 in this demo, while the AuthBridge default is all-interfaces, so production should keep them loopback-bound (or add a NetworkPolicy) and authenticate the session API. Signed-off-by: Frederico Araujo --- authbridge/authlib/go.sum | 2 -- authbridge/cmd/authbridge-cpex/go.sum | 2 -- authbridge/demos/hr-cpex/README.md | 11 ++++++++ authbridge/demos/hr-cpex/agent/Dockerfile | 4 ++- authbridge/demos/hr-cpex/k8s/30-agent.yaml | 27 +++++++++++++++++++ authbridge/demos/hr-cpex/k8s/cpex-policy.yaml | 20 ++++++++------ 6 files changed, 53 insertions(+), 13 deletions(-) diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 753c03e83..af2c59aec 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -30,8 +30,6 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 h1:HWKqhOoh3dEscE8jWe0dYiOOegLW1liQmQ/RqBj7+Ks= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 h1:Ddpq+JFjnA7G4VUiHJPP2520GPmIv2XmikUV2n/gRd0= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/authbridge/cmd/authbridge-cpex/go.sum b/authbridge/cmd/authbridge-cpex/go.sum index 46b1606ad..177da5ff5 100644 --- a/authbridge/cmd/authbridge-cpex/go.sum +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -18,8 +18,6 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1 h1:HWKqhOoh3dEscE8jWe0dYiOOegLW1liQmQ/RqBj7+Ks= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.1/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 h1:Ddpq+JFjnA7G4VUiHJPP2520GPmIv2XmikUV2n/gRd0= github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md index bfd7c1b58..cbf0bcbbb 100644 --- a/authbridge/demos/hr-cpex/README.md +++ b/authbridge/demos/hr-cpex/README.md @@ -440,3 +440,14 @@ make undeploy # deletes the cpex-demo namespace land first. - **Production TLS.** Keycloak runs on plaintext HTTP. Wire a cert via cert-manager and disable the `insecure_http` flags for real use. +- **Internal endpoint hardening.** The sidecar's session API (`:9094`) and + stats endpoint (`:9093`) have no authentication. This demo binds both to + `127.0.0.1` in `authbridge-config` (see `session_api_addr` / + `stats_address` in `k8s/30-agent.yaml`), so they are reachable only from + inside the pod and via `kubectl port-forward`, not cluster-wide. Note the + AuthBridge default for these addresses is all-interfaces (`:9094` / + `:9093`); a deployment that does not override them to loopback would expose + them on the pod IP. Since the session API can surface session labels and + request content, production should keep them loopback-bound (or add a + NetworkPolicy) and add authentication on the session API. Do not expose + either port through a Service. diff --git a/authbridge/demos/hr-cpex/agent/Dockerfile b/authbridge/demos/hr-cpex/agent/Dockerfile index 495f0c431..7acf14e94 100644 --- a/authbridge/demos/hr-cpex/agent/Dockerfile +++ b/authbridge/demos/hr-cpex/agent/Dockerfile @@ -8,7 +8,9 @@ # The Deployment uses imagePullPolicy: Never so the kubelet takes the # locally-loaded image rather than pulling from a registry. -FROM python:3.12-slim +# Pinned by digest (multi-arch index) to match the SHA-pinned authbridge-cpex +# image in this PR; bump deliberately rather than floating on the tag. +FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 WORKDIR /app diff --git a/authbridge/demos/hr-cpex/k8s/30-agent.yaml b/authbridge/demos/hr-cpex/k8s/30-agent.yaml index 8b27c847c..30b0f2c5c 100644 --- a/authbridge/demos/hr-cpex/k8s/30-agent.yaml +++ b/authbridge/demos/hr-cpex/k8s/30-agent.yaml @@ -75,6 +75,24 @@ data: # Bypass hosts default covers keycloak/SPIRE/observability so # those egress calls don't pay the FFI cost. +--- +# Gateway OAuth client secret, mounted into the sidecar as a file so the CPEX +# policy can reference it with `client_secret_source.kind: file` instead of +# inlining the value in the policy manifest. This models the production +# pattern (secret stays in a Secret, not in committed policy YAML). +# +# DEMO ONLY: the value itself is a well-known demo secret and the Secret is +# created from plaintext here. A real deployment sources it from an external +# secret manager / sealed secret and never commits the literal. +apiVersion: v1 +kind: Secret +metadata: + name: cpex-gateway-secret + namespace: cpex-demo +type: Opaque +stringData: + client-secret: "cpex-gateway-secret" + --- apiVersion: v1 kind: Service @@ -216,6 +234,12 @@ spec: - name: cpex-policy mountPath: /etc/cpex readOnly: true + # Gateway client secret as a file (kind: file in the policy). + # Separate mount path from /etc/cpex so it doesn't collide with + # the cpex-policy ConfigMap mount. + - name: cpex-gateway-secret + mountPath: /etc/cpex-secrets + readOnly: true readinessProbe: # /readyz gates on plugin readiness (ANDs Ready() across # plugins); /healthz is always 200 and only fits liveness. @@ -238,3 +262,6 @@ spec: - name: cpex-policy configMap: name: cpex-policy + - name: cpex-gateway-secret + secret: + secretName: cpex-gateway-secret diff --git a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml index 2453b993f..888cc34ec 100644 --- a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml +++ b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml @@ -1,9 +1,13 @@ # CPEX runtime config for the HR demo. # -# DEMO ONLY — this file embeds hardcoded client secrets -# (client_secret_source.kind: literal) and points at a local Keycloak -# over plaintext http. Do NOT use in production: source secrets from a -# mounted file / Secret (kind: file) and use TLS to the IdP. +# DEMO ONLY — points at a local Keycloak over plaintext http. Do NOT use +# in production without TLS to the IdP. +# +# The gateway client secret is sourced with `client_secret_source.kind: file` +# from a mounted Secret (see the cpex-gateway-secret Secret + /etc/cpex-secrets +# mount in k8s/30-agent.yaml), so the secret value is NOT inlined in this +# policy manifest. The `literal` and `env_var` source kinds also exist; file +# is the production-shaped one. # # ── How to read this file ─────────────────────────────────────────── # The `routes:` block at the BOTTOM is the story: three tool routes, @@ -110,8 +114,8 @@ plugins: insecure_http: true client_id: "cpex-gateway" client_secret_source: - kind: literal - secret: "cpex-gateway-secret" + kind: file + path: /etc/cpex-secrets/client-secret timeout_seconds: 5 default_outbound_header: "Authorization" @@ -139,8 +143,8 @@ plugins: insecure_http: true client_id: "cpex-gateway" client_secret_source: - kind: literal - secret: "cpex-gateway-secret" + kind: file + path: /etc/cpex-secrets/client-secret timeout_seconds: 5 default_outbound_header: "Authorization" From 47d6828f6ce9118497748394670052ab0015a262 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 16 Jun 2026 12:25:18 -0400 Subject: [PATCH 14/18] fix(cpex): address PR #493 review on awaitBackground + JSON logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two non-blocking review items in the cpex plugin: - awaitBackground: raise backgroundWaitTimeout 30s -> 65s, just above the FFI's own 60s wall-clock bound (run_safely wraps wait_for_background_tasks in tokio::time::timeout). With 30s < 60s a sink taking 31-60s fired a spurious 'timed out' WARN and dropped the real sub-plugin error report (the inner goroutine returned after the outer had already given up). At 65s the done arm wins, so the report is logged and the inner goroutine is reliably drained. Corrected the doc comments to stop over-claiming ('can't leak without bound'): the FFI 60s timeout is the primary bound; the inner goroutine only orphans under full tokio-runtime starvation. That residual leak is not fixable Go-side (can't cancel a goroutine parked in a cgo call) and is deferred via an explicit TODO(cpex-ffi-cancel) to a follow-up PR on the next CPEX release, sharing the fix with the Initialize TODO (both need an abortable FFI call). - cmf_body.go: add debug logs on the response-body extraction path (extractToolResultFromBody) when the body isn't valid JSON, or parses but isn't a tools/call result shape we can project into CMF. Logs sizes only, never body content (PII/secret safety). Debug level — these are common, benign cases. Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/cmf_body.go | 16 +++++- .../authlib/plugins/cpex/manager_cpex.go | 53 +++++++++++++------ 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/authbridge/authlib/plugins/cpex/cmf_body.go b/authbridge/authlib/plugins/cpex/cmf_body.go index 34987b732..67f731a26 100644 --- a/authbridge/authlib/plugins/cpex/cmf_body.go +++ b/authbridge/authlib/plugins/cpex/cmf_body.go @@ -3,6 +3,7 @@ package cpex import ( "encoding/json" "fmt" + "log/slog" "net/http" "strconv" "strings" @@ -337,11 +338,22 @@ func extractToolResultFromBody(body []byte) any { return nil } var envelope map[string]any - if json.Unmarshal(body, &envelope) != nil { + if err := json.Unmarshal(body, &envelope); err != nil { + // Benign and common (e.g. a non-JSON tool result), so debug only. + // Log the size, never the body — it can carry secrets/PII. + slog.Debug("cpex: tools/call response body is not valid JSON; result.* predicates will not resolve", + "bytes", len(body)) return nil } result, _ := envelope["result"].(map[string]any) - return extractToolResultContent(result) + content := extractToolResultContent(result) + if content == nil { + // Valid JSON, but not a tools/call result shape we can project into + // CMF (wrong types / missing fields). Debug only; no body content. + slog.Debug("cpex: could not extract a tool result from the response (unexpected shape); result.* predicates will not resolve", + "bytes", len(body)) + } + return content } // extractToolResultContent pulls the inner tool payload out of an MCP diff --git a/authbridge/authlib/plugins/cpex/manager_cpex.go b/authbridge/authlib/plugins/cpex/manager_cpex.go index 6be1e1acd..89bfc8788 100644 --- a/authbridge/authlib/plugins/cpex/manager_cpex.go +++ b/authbridge/authlib/plugins/cpex/manager_cpex.go @@ -65,9 +65,10 @@ func (c *cpexManager) LoadConfig(yaml string) error { // finishes on its own when cpex_initialize returns; its handle is // reclaimed by the framework's later Shutdown (or the GC finalizer). // -// TODO: switch to a context-aware Initialize if the rcpex bindings -// gain one, so a stuck init can be cancelled rather than merely -// abandoned. +// TODO(cpex-ffi-cancel): switch to a context-aware Initialize if the rcpex +// bindings gain one, so a stuck init can be cancelled rather than merely +// abandoned. Shares the follow-up with awaitBackground's wait_for_background_tasks +// (both need an abortable FFI call); lands on the next CPEX release. func (c *cpexManager) Initialize(ctx context.Context) error { done := make(chan error, 1) go func() { @@ -212,32 +213,50 @@ func lowerHeaders(h http.Header) map[string]string { return out } -// backgroundWaitTimeout bounds how long awaitBackground blocks on a -// single Invoke's background tasks. A stalled sink (e.g. an audit -// endpoint that never responds) would otherwise pin one goroutine per -// request indefinitely; the deadline caps the leak at one goroutine for -// at most this long. Best-effort — the result only feeds slog — so a -// generous value avoids dropping work product from merely-slow sinks. -const backgroundWaitTimeout = 30 * time.Second +// backgroundWaitTimeout is a backstop on how long awaitBackground's outer +// goroutine waits for a single Invoke's background tasks. It is set just +// ABOVE the FFI's own wall-clock bound (FFI_WALL_CLOCK_TIMEOUT, ~60s, which +// run_safely wraps around wait_for_background_tasks in cpex-ffi). In normal +// operation bg.Wait returns at or before that FFI bound, so the `done` arm +// in awaitBackground wins and this timer never fires; it exists only so the +// outer goroutine can't block forever if the FFI never returns (a fully +// starved tokio runtime whose timer can't fire). Keep it > the FFI bound. +const backgroundWaitTimeout = 65 * time.Second // awaitBackground blocks on bg.Wait — which returns when every // background sub-plugin spawned by this Invoke has finished — and // logs the per-sub-plugin error report (if any) via slog. Operators // pipe the slog output to their audit/observability stack. // -// Bounded by backgroundWaitTimeout: bg.Wait runs in an inner goroutine -// and we select on it vs a timer. On timeout we log a WARN and return, -// so a stalled sink can't leak goroutines without bound. The inner -// goroutine still exits whenever bg.Wait eventually returns. +// Two-layer bound. bg.Wait runs in an inner goroutine; we select it +// against backgroundWaitTimeout. The PRIMARY bound is the FFI itself: +// cpex_wait_background runs wait_for_background_tasks under run_safely's +// tokio::time::timeout (FFI_WALL_CLOCK_TIMEOUT, ~60s), so bg.Wait returns — +// successfully, or with an RC_TIMEOUT error — within that window even +// against a merely-hung sink. Because backgroundWaitTimeout is set above +// that bound, the `done` arm wins in normal operation and the inner +// goroutine is reclaimed every time; the timer is only a backstop for the +// case where the FFI never returns at all. +// +// Residual leak (documented, not fixed here): if the tokio runtime is fully +// starved — every worker pinned by non-yielding / CPU-bound or +// std::thread::sleep-ing sub-plugins, so even the FFI's own timer can't +// fire — bg.Wait never returns, and this inner goroutine (plus the +// *rcpex.BackgroundTasks it holds) cannot be reclaimed: the timer frees the +// OUTER goroutine, but Go cannot cancel a goroutine parked in a cgo call. +// +// TODO(cpex-ffi-cancel): this residual starvation leak is not fixable +// Go-side (you can't cancel a goroutine parked in a cgo call). Give cpex-ffi +// an abortable wait_for_background_tasks so the inner goroutine is +// reclaimable; lands in a follow-up PR on the next CPEX release. Same root +// cause as the Initialize TODO above. // // Concurrency notes: // - The goroutine outlives the request; it does NOT touch pctx // (pctx may be reused by the framework after Invoke returns). // hook and reqID are captured by value. // - If the manager shuts down while we're waiting, bg.Wait -// returns an error which we log at WARN. We do not attempt to -// cancel — the underlying FFI doesn't expose a cancel knob, and -// a graceful shutdown should drain background tasks anyway. +// returns an error which we log at WARN. // - elapsed gives operators a knob to spot slow async work in the // log stream (e.g. a slow audit sink). func awaitBackground(hook, reqID string, bg *rcpex.BackgroundTasks) { From 2bf264a052b2e639e53ccdfb97f03b347fb4a00c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 16 Jun 2026 12:25:54 -0400 Subject: [PATCH 15/18] docs(cpex): align demo + docs with MCP-frame authorization-error rendering After merging PR #501 (shared MCP rejection renderer, httpx/render.go), cpex denials on the HTTP proxies render as an MCP JSON-RPC 2.0 error frame at HTTP 200 for classified MCP requests (code -32000, error.data.error=cpex.), and fall back to plain 403/502 only for non-MCP requests. The demo and docs still described the old transport-level 403. Truth them up: - Chat agent (agent.py): format_tool_response reads the cpex code from error.data.error (was error.data.violation, which the renderer never emits). Verified live in-container against a real deny frame. Fix a stale '-> 403' comment. - Scenarios 02/05/06/07/08/09: headers + notes now expect HTTP 200 + JSON-RPC error frame (error.data.error=cpex.*), not HTTP 403. Re-ran all 9: deny frames match. - README: taint S3 line now says denied via MCP JSON-RPC error frame. - CHAT-WALKTHROUGH.md: fix all four deny sections (code -32001 -> -32000, data.violation -> data.error with the real cpex codes). - Plugin docs (plugins/cpex/README.md, docs/cpex-plugin.md): the deny/error wire shape is now described as listener-dependent (MCP frame at 200 vs plain 403/502). Also correct the stale 'body modifications aren't re-serialized' limitation: re-serialization is implemented; the real limitation is that multi-part rewrites fail closed. Signed-off-by: Frederico Araujo --- authbridge/authlib/plugins/cpex/README.md | 38 ++++++++++--- authbridge/demos/hr-cpex/README.md | 2 +- .../demos/hr-cpex/agent/CHAT-WALKTHROUGH.md | 16 +++--- authbridge/demos/hr-cpex/agent/agent.py | 8 ++- .../demos/hr-cpex/scenarios/02-alice-deny.sh | 11 ++-- .../scenarios/05-alice-external-cedar-deny.sh | 9 +-- .../hr-cpex/scenarios/06-bob-apl-deny.sh | 7 ++- .../hr-cpex/scenarios/07-bob-pii-deny.sh | 8 +-- .../hr-cpex/scenarios/08-bob-taint-deny.sh | 4 +- .../09-cross-principal-taint-isolation.sh | 4 +- authbridge/docs/cpex-plugin.md | 56 +++++++++++++------ 11 files changed, 106 insertions(+), 57 deletions(-) diff --git a/authbridge/authlib/plugins/cpex/README.md b/authbridge/authlib/plugins/cpex/README.md index 28a6a7858..0898cea81 100644 --- a/authbridge/authlib/plugins/cpex/README.md +++ b/authbridge/authlib/plugins/cpex/README.md @@ -233,7 +233,7 @@ taint all run inside cpex via its own sub-plugins, so the AuthBridge-side | `hooks.on_response` | empty | CPEX hooks fired during OnResponse, in order | | `config` | empty | CPEX runtime YAML inline; mutually exclusive with `config_file` | | `config_file` | empty | path to CPEX YAML; mounted from a ConfigMap in production | -| `fail_open` | `false` | on a CPEX internal error: deny with 502 (false) or continue (true) | +| `fail_open` | `false` | on a CPEX internal error: deny (false) or continue (true). The rejection renders per the listener — MCP error frame for MCP requests, else HTTP 502 (see Decisions and denials) | | `worker_threads` | `0` | CPEX tokio worker pool size; 0 lets CPEX pick | | `bypass_paths` | health + `.well-known` | URL path globs that skip CPEX entirely | | `bypass_hosts` | keycloak / SPIRE / observability | host globs that skip CPEX, outbound only | @@ -256,19 +256,41 @@ The plugin maps the CPEX outcome onto AuthBridge's five-value invocation vocabulary: - **allow**: all sub-plugins continued, request proceeds untouched. -- **deny**: a sub-plugin returned a policy violation. Rejects with HTTP 403 and a - `cpex.` violation code (`cpex.pii_detected`, - `cpex.session_tainted_secret`, and so on). +- **deny**: a sub-plugin returned a policy violation with a `cpex.` code + (`cpex.pii_detected`, `cpex.session_tainted_secret`, and so on). The plugin + emits a `Reject`; how it appears on the wire is the listener's call, not the + plugin's (see below). - **modify**: policy rewrote the body, headers, or labels. Changes are applied, the request continues. - **observe**: audit-only outcome, recorded, request continues. +**How a rejection is rendered.** The plugin sets the decision and a status +(`403` for a policy deny, `502` for a CPEX-internal error); the HTTP/proxy +listeners then choose the wire shape (`authbridge/authlib/listener/httpx/render.go`): + +- **Classified MCP request** (an MCP parser populated the MCP extension with a + JSON-RPC `method` and `id`) → an **MCP JSON-RPC 2.0 error frame at HTTP 200**, + with the original id echoed back: + `{"jsonrpc":"2.0","id":,"error":{"code":-32000,"message":,"data":{"error":"cpex.","plugin":"cpex"}}}`. + The caller's MCP client surfaces this as a failed tool call rather than a + transport break. This covers both a policy deny and a fail-closed internal + error — they differ in the frame's `message` / `data.error`, not the HTTP + status. +- **Everything else** (non-MCP requests, or JSON-RPC notifications with no id) → + a plain HTTP response carrying the status the plugin set: **403 Forbidden** for + a policy deny, **502 Bad Gateway** for a CPEX-internal error, with a + `{"error":"cpex.",...}` body. + +(The gRPC listeners — extproc / extauthz — don't use this renderer and surface +denials per their own protocol.) + A CPEX policy `deny` is a normal outcome and is always honored. `fail_open` only governs CPEX-internal failures (an FFI error, an unreachable backend, a -modification that cannot be applied). With `fail_open: false` those reject with -HTTP 502; with `true` they log and continue. The default is fail-closed because a -CPEX error usually means a misconfigured policy or an unreachable PDP, and -silently allowing traffic in that state is rarely intended. +modification that cannot be applied): with `fail_open: false` those are rejected +(rendered as above — an MCP error frame for MCP requests, otherwise HTTP 502); +with `true` they log and continue. The default is fail-closed because a CPEX +error usually means a misconfigured policy or an unreachable PDP, and silently +allowing traffic in that state is rarely intended. ## Building and testing diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md index cbf0bcbbb..50499742b 100644 --- a/authbridge/demos/hr-cpex/README.md +++ b/authbridge/demos/hr-cpex/README.md @@ -129,7 +129,7 @@ content, which is what distinguishes it from scenario 07's content-based PII deny. Scenario 08 shows all three beats with fresh per-run session ids: a clean session sends fine (S1 → 200), reading compensation taints a second session (S2 → 200), and that session can no longer send email -(S3 → 403 `cpex.session_tainted_secret`). +(S3 → denied with an MCP JSON-RPC error frame, `cpex.session_tainted_secret`). **Act 5 — cross-principal taint isolation (scenario 09).** Session taint is keyed by the authenticated subject, not the raw `X-Session-Id`: the diff --git a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md index 6bcf7be33..bee18c060 100644 --- a/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md +++ b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md @@ -76,8 +76,8 @@ Alice: look up the compensation for EMP-001234 **Expected:** - LLM invokes `get_compensation(employee_id="EMP-001234")` -- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, - `data.violation = "routes.tool:get_compensation.apl.policy[0]"` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`, + `data.error = "cpex.routes_tool_get_compensation_apl_policy_0_"` - LLM apologizes politely without revealing the violation code (system prompt tells it to do exactly this) @@ -171,8 +171,8 @@ short-circuited at the PDP.) **Expected:** - LLM invokes `search_repos(repo_name="partner-sdk", visibility="external")` -- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, - `data.violation = "cedar.default_deny"` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`, + `data.error = "cpex.cedar_default_deny"` - LLM apologizes **Under the hood:** @@ -198,8 +198,8 @@ Bob: search the engineering repos for anything **Expected:** - LLM invokes `search_repos(visibility="internal")` (or similar) -- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, - `data.violation = "routes.tool:search_repos.apl.policy[0]"` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`, + `data.error = "cpex.routes_tool_search_repos_apl_policy_0_"` - LLM apologizes **Talking point:** the APL gate denied at the cheapest layer — Bob is @@ -220,8 +220,8 @@ Bob: send an email to alice@corp.com with the subject "FYI" **Expected:** - LLM invokes `send_email(to="alice@corp.com", subject="FYI", body="Jane's SSN is 555-12-3456 if you need it")` -- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`, - `data.violation = "pii.detected"` +- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`, + `data.error = "cpex.pii_detected"` - LLM apologizes **Under the hood:** diff --git a/authbridge/demos/hr-cpex/agent/agent.py b/authbridge/demos/hr-cpex/agent/agent.py index 046619ac6..e575bb5dd 100644 --- a/authbridge/demos/hr-cpex/agent/agent.py +++ b/authbridge/demos/hr-cpex/agent/agent.py @@ -161,7 +161,7 @@ # result.ssn for callers without view_ssn. An `ssn` echo-back # arg used to live here, but models populate it # inconsistently (""/null/omitted) and a null value trips the - # APL redact(args.ssn) type check (expected Str) → 403. The + # APL redact(args.ssn) type check (expected Str) → deny. The # request-side args.ssn redaction is still exercised by the # deterministic curl matrix (scenarios/01-bob-allow.sh). }, @@ -269,11 +269,15 @@ def format_tool_response(status: int, data: dict[str, Any]) -> str: if status >= 400: return json.dumps({"gateway_status": status, "error": data}) if "error" in data: + # MCP JSON-RPC 2.0 error frame (HTTP 200). The forward proxy renders + # cpex denials this way for MCP requests; the violation code lives at + # error.data.error (see authbridge/authlib/listener/httpx/render.go), + # NOT error.data.violation. err = data["error"] return json.dumps( { "error": err.get("message", "tool error"), - "violation": (err.get("data") or {}).get("violation"), + "violation": (err.get("data") or {}).get("error"), } ) result = data.get("result", {}) diff --git a/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh index 9ac3a1aff..3dc6837e2 100755 --- a/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # Alice (engineer, role.engineer) calls get_compensation. Expected: # -# * Gateway returns HTTP 403 with a JSON error body -# {"error":"cpex.",...,"plugin":"cpex"}. A CPEX -# policy deny is an authorization decision, surfaced by AuthBridge -# as a transport-level 403 (not a 500, not an MCP JSON-RPC envelope) +# * Gateway denies with an MCP JSON-RPC 2.0 error frame at HTTP 200: +# error.message = the human reason, error.data.error = the cpex code +# (cpex.), error.data.plugin = cpex. The forward proxy +# renders MCP-protocol errors for MCP requests (see +# authbridge/authlib/listener/httpx/render.go). # * No token exchange happened (Keycloak's /token endpoint # should NOT receive a token-exchange call for this request) # * MCP server NEVER sees the call (request short-circuits at policy) @@ -13,7 +14,7 @@ set -euo pipefail source "$(dirname "$0")/_lib.sh" step "Alice (engineer) → get_compensation" -note "Expected: HTTP 403, code=cpex.routes_tool_get_compensation_apl_policy_0_" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_get_compensation_apl_policy_0_" note "Triggered by: require(role.hr) deny BEFORE delegation runs" note "Expected upstream: no inbound request (gateway short-circuited)" diff --git a/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh index dae00e422..e6c1063c7 100755 --- a/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh @@ -10,15 +10,16 @@ # Layers 3-4 — never reached. No token exchange. GitHub never sees # the request. # -# Result: HTTP 403 with JSON error body, code = cpex.cedar_default_deny -# — a CPEX policy deny is surfaced by AuthBridge as a transport-level -# 403 (not a 500, not an MCP JSON-RPC envelope). +# Result: HTTP 200 with an MCP JSON-RPC 2.0 error frame, +# error.data.error = cpex.cedar_default_deny (the forward proxy renders +# MCP-protocol errors for MCP requests; see +# authbridge/authlib/listener/httpx/render.go). set -euo pipefail source "$(dirname "$0")/_lib.sh" step "Alice (engineering) → search_repos(visibility='external')" -note "Expected: HTTP 403, code=cpex.cedar_default_deny" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.cedar_default_deny" note "Triggered by: Cedar denies — engineering can't read external repos" note "Expected upstream: no inbound request (gateway short-circuits at PDP)" diff --git a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh index 5b845a0fb..5dfa1f6b7 100755 --- a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh @@ -12,14 +12,15 @@ # Layers 2-4 — never reached. Cedar never invoked, IdP never # called, no token-exchange round-trip. # -# Result: HTTP 403 with JSON error body, code = the cpex.* form of the -# apl.policy step index that failed (cpex.routes_tool_search_repos_apl_policy_0_). +# Result: HTTP 200 with an MCP JSON-RPC 2.0 error frame; error.data.error = +# the cpex.* form of the apl.policy step index that failed +# (cpex.routes_tool_search_repos_apl_policy_0_). set -euo pipefail source "$(dirname "$0")/_lib.sh" step "Bob (HR) → search_repos (gateway short-circuits at the APL gate)" -note "Expected: HTTP 403, code=cpex.routes_tool_search_repos_apl_policy_0_" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_search_repos_apl_policy_0_" note "Triggered by: require(team.engineering | team.security) — Bob is team.hr" note "Expected: Cedar never runs; IdP never called" diff --git a/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh b/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh index 025e3c00d..4901bd501 100755 --- a/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh @@ -12,9 +12,9 @@ # its observation record for the deny, so operators see the event. # # Expected: -# * HTTP 403 with JSON error body, code = cpex.pii_detected — a CPEX -# policy deny is surfaced by AuthBridge as a transport-level 403 -# (not a 500, not an MCP JSON-RPC envelope) +# * HTTP 200 with an MCP JSON-RPC 2.0 error frame, error.data.error = +# cpex.pii_detected (the forward proxy renders MCP-protocol errors for +# MCP requests; see authbridge/authlib/listener/httpx/render.go) # * Backend (hr-mcp) NEVER receives the call — the deny is # enforced at the gateway plugin layer # * stderr (from the gateway process) shows a JSON audit record @@ -24,7 +24,7 @@ set -euo pipefail source "$(dirname "$0")/_lib.sh" step "Bob (HR + email_send) → send_email with SSN in body" -note "Expected: HTTP 403, code=cpex.pii_detected" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.pii_detected" note "Triggered by: pii-scan plugin catches the SSN pattern in args" note "Expected: audit-log still emits a record describing the deny" note "Expected upstream: no inbound request (gateway plugin denied)" diff --git a/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh b/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh index 140127abf..024c83772 100755 --- a/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh @@ -18,7 +18,7 @@ # Three steps: # S1 clean session, clean body → 200 (baseline: email works) # S2 new session, get_compensation → 200 (taints session "secret") -# S3 SAME session as S2, clean body → 403 cpex.session_tainted_secret +# S3 SAME session as S2, clean body → 200 + JSON-RPC error frame, cpex.session_tainted_secret # # Watch the gateway with: kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex @@ -44,7 +44,7 @@ SESSION_ID="$SID_TAINT" call_get_compensation "$BOB" "$CLIENT" true step "S3 · Bob → send_email (SAME session as S2, clean body)" note "Session: $SID_TAINT (now carries label \"secret\" from S2)" -note "Expected: HTTP 403, code=cpex.session_tainted_secret" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.session_tainted_secret" note "Denied by the session taint — NOT by pii-scan (the body has no PII)" note "This is the cross-tool data-flow control: looked at secrets → can't email out" SESSION_ID="$SID_TAINT" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY" diff --git a/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh b/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh index 6b7f72552..ba61787db 100755 --- a/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh +++ b/authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh @@ -28,7 +28,7 @@ # # Contrast with scenario 08, where bob reusing his OWN tainted session # is denied (cpex.session_tainted_secret). Same id, different subject = -# different outcome. Pre-fix, S3 would have returned 403. +# different outcome. Pre-fix, S3 would have been denied. # # Watch the gateway with: kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex @@ -57,5 +57,5 @@ step "S3 · Bob → send_email, SAME session id as eve used, clean body" note "Session: $SID_SHARED (same string — but bound to bob's subject now)" note "Expected: 200 OK — bob's key H(bob:$SID_SHARED) != eve's H(eve:$SID_SHARED)," note "so bob does NOT inherit eve's \"secret\" taint. THIS is the Finding 2 fix." -note "Pre-fix (raw session key) this would have been 403 cpex.session_tainted_secret." +note "Pre-fix (raw session key) this would have been denied (cpex.session_tainted_secret)." SESSION_ID="$SID_SHARED" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY" diff --git a/authbridge/docs/cpex-plugin.md b/authbridge/docs/cpex-plugin.md index 06ae91b2a..bc12f3b72 100644 --- a/authbridge/docs/cpex-plugin.md +++ b/authbridge/docs/cpex-plugin.md @@ -89,9 +89,9 @@ fake Manager. Each AuthBridge phase (`OnRequest`, `OnResponse`) dispatches an ordered list of CPEX hook names. Hooks fire one at a time; the chain short-circuits on the first sub-plugin that returns deny. A sub-plugin -that returns `modify` records the Invocation, applies header/label -changes to pctx, and lets the chain continue. CPEX's standard hook -names: +that returns `modify` records the Invocation, applies its modifications +to pctx (rewritten body, mutated headers, session labels), and lets the +chain continue. CPEX's standard hook names: | Hook | Fires for | Typical sub-plugins | |---|---|---| @@ -193,7 +193,7 @@ plugins: ## Status codes & reasons -| Code | HTTP | Meaning | +| Code | HTTP (non-MCP) | Meaning | |---|---|---| | `cpex.error` | 502 | CPEX FFI returned an error and `fail_open: false`. Body carries the underlying error message. | | `cpex.denied` | 403 | A CPEX sub-plugin returned deny without a violation code. | @@ -203,13 +203,26 @@ The `Invocation.Reason` field carries the CPEX-side reason string for all of the above so operator dashboards can render the original sub-plugin message alongside the namespaced code. -**HTTP status.** A policy `deny` is an authorization decision and -renders as **403 Forbidden**; a CPEX-internal error (fail-closed) -renders as **502 Bad Gateway** (the policy engine itself failed — an -upstream fault, not a client error). The plugin sets both statuses -explicitly: the namespaced `cpex.*` codes are dynamic and never appear -in the listener's static `codeToStatus` table, so without an explicit -status `Violation.Render` would default every CPEX outcome to 500. +**HTTP status / wire shape.** The plugin sets a status on every +rejection — **403 Forbidden** for a policy `deny`, **502 Bad Gateway** +for a CPEX-internal error (the policy engine itself failed — an upstream +fault, not a client error). It must set these explicitly: the namespaced +`cpex.*` codes are dynamic and never appear in the listener's static +`codeToStatus` table, so without an explicit status `Violation.Render` +would default every CPEX outcome to 500. + +How that reaches the client depends on the listener and the request. On +the HTTP proxies (forward / reverse), a **classified MCP request** (the +MCP extension is populated with a JSON-RPC `method` and `id`) is rendered +as an **MCP JSON-RPC 2.0 error frame at HTTP 200**, with the id echoed +back: +`{"jsonrpc":"2.0","id":,"error":{"code":-32000,"message":,"data":{"error":"cpex.","plugin":"cpex"}}}`. +The caller's MCP client then surfaces a failed tool call rather than a +transport break, and the `cpex.*` code rides in `error.data.error` (not +the HTTP status). Non-MCP requests — and JSON-RPC notifications with no +id — get the plain 403 / 502 in the table above. (See +`authbridge/authlib/listener/httpx/render.go`; the gRPC listeners, +extproc / extauthz, don't use this renderer.) ## Bypass list curation @@ -296,12 +309,19 @@ need to route background results separately from foreground logs. ## Limitations -- **Body modifications aren't yet re-serialized.** When a CPEX - sub-plugin returns `modify` with a body payload change (the PII - scanner's redaction path is the canonical case), the cpex plugin - applies header and label changes to pctx but logs a warning and - leaves `pctx.Body` unchanged. Format-aware re-serialization - (CMF → JSON-RPC / OpenAI) is on the roadmap. +- **Single-value body redaction; multi-part rewrites fail closed.** Body + modifications **are** re-serialized. When a CPEX sub-plugin returns + `modify` with a body payload change (the PII scanner / `redact` path is + the canonical case), the cpex plugin rewrites `pctx.Body` format-aware + per protocol — MCP `tools/call` arguments and results, inference + messages, and A2A artifact parts (see `applyBodyModFromCMF` and the + per-format `applyMCP*/applyInference*/applyA2A*` write-backs). The + residual limitation is value cardinality: the CMF write-back carries a + single redacted value, so a body with more than one redactable text + part (an MCP result with multiple text blocks, a multi-part A2A + artifact) is ambiguous to rewrite and is **rejected fail-closed** rather + than partially redacted. Single-part bodies — the common case, and what + the demo exercises — redact and forward normally. - **Per-sub-plugin Invocations** require the CPEX FFI to expose per-sub-plugin outcomes; today CPEX returns an aggregate `PipelineResult` with a single `Violation`. The cpex plugin emits @@ -319,7 +339,7 @@ need to route background results separately from foreground logs. | Pipeline build fails at boot: `cpex plugin: this binary was not built with -tags cpex` | Operator pointed `authbridge-proxy` (no cgo) at a config containing a `cpex` plugin. | Use the `authbridge-cpex` image instead; or rebuild with `-tags cpex` against a downloaded `libcpex_ffi.a`. | | Pipeline build fails: `cpex bypass_paths: invalid bypass pattern` | An entry in `bypass_paths` has bad `path.Match` syntax. | Fix the glob pattern (`/api/*/v1`, `/static/**` etc.). | | Pipeline build fails: `cpex config: pattern "*" matches everything` | Operator wrote a wildcard pattern in `bypass_hosts` or `bypass_paths`. | If you want to disable cpex, remove it from the pipeline; don't bypass everything. | -| All traffic returns 502 with `cpex.error`, JWKS errors in logs | CPEX's APL identity plugin can't reach Keycloak. | Check the `apl.identity.jwt.jwks_url` is reachable from inside the pod; check the cpex bypass list doesn't include Keycloak (it should). | +| All traffic is rejected with `cpex.error` (a 200 MCP error frame for MCP calls, else 502), JWKS errors in logs | CPEX's APL identity plugin can't reach Keycloak. | Check the `apl.identity.jwt.jwks_url` is reachable from inside the pod; check the cpex bypass list doesn't include Keycloak (it should). | | Audit logger silently produces no records | `BackgroundTasks` goroutine started but the audit sink is unreachable. | Search the slog stream for `cpex: background sub-plugin error` — the request ID and elapsed time correlate the failure with the foreground request. | ## See also From 57044faca8a07d5ffa0e1909d3aa131eed4e474a Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 22 Jun 2026 09:46:15 -0400 Subject: [PATCH 16/18] fix: small regression introduced when add demo warning to keycloak real export configuration Signed-off-by: Frederico Araujo --- authbridge/demos/hr-cpex/k8s/10-keycloak.yaml | 6 ++++++ authbridge/demos/hr-cpex/k8s/realm-export.json | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml index 0a84c3533..e55110d93 100644 --- a/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml +++ b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml @@ -17,6 +17,12 @@ # inline a placeholder ConfigMap here — its data would race with the # file-driven one (whoever runs second wins) and Keycloak would fail # realm import on the placeholder content. +# +# DEMO ONLY: the passwords and client secrets in realm-export.json are +# for local development. Do not use in production. (This note lives here, +# not as a `_WARNING` key inside realm-export.json — Keycloak 26's +# `--import-realm` rejects unknown top-level RealmRepresentation fields, +# which crashes the import on a fresh-DB boot.) apiVersion: v1 kind: Service diff --git a/authbridge/demos/hr-cpex/k8s/realm-export.json b/authbridge/demos/hr-cpex/k8s/realm-export.json index dcb933ad3..71367bc1f 100644 --- a/authbridge/demos/hr-cpex/k8s/realm-export.json +++ b/authbridge/demos/hr-cpex/k8s/realm-export.json @@ -1,5 +1,4 @@ { - "_WARNING": "DEMO ONLY — passwords and client secrets in this file are for local development. Do not use in production.", "realm": "cpex-demo", "enabled": true, "displayName": "CPEX Demo Realm", From 26740cf64e8b5715d395fd5ae735f29f3d41cfad Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 17 Jul 2026 17:28:25 -0400 Subject: [PATCH 17/18] fix(cpex): bump litellm to 1.84.0 to clear dependency-review vulns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit litellm 1.83.10 (the previous pin) is flagged by two newer advisories that fail the PR dependency-review gate (fail-on-severity: moderate): * GHSA-4xpc-pv4p-pm3w — authentication bypass via Host header injection (critical; vulnerable < 1.84.0) * GHSA-qrc4-49gv-mv9m — internal_user can create API keys beyond their role (high; vulnerable < 1.83.14) 1.84.0 is the lowest release that clears both (and every other known litellm advisory), consistent with the file's lowest-safe-release pin convention. Assisted-By: Claude (Anthropic AI) Signed-off-by: Frederico Araujo --- authbridge/demos/hr-cpex/agent/requirements.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/authbridge/demos/hr-cpex/agent/requirements.txt b/authbridge/demos/hr-cpex/agent/requirements.txt index bc0eca7cd..c89ee9819 100644 --- a/authbridge/demos/hr-cpex/agent/requirements.txt +++ b/authbridge/demos/hr-cpex/agent/requirements.txt @@ -17,11 +17,14 @@ uvicorn[standard]==0.32.0 # LLM provider abstraction — supports OpenAI, Anthropic, Ollama, WatsonX, # Azure, Bedrock, and more, so the demo runs against whatever the operator # has access to without code changes. litellm is pinned at the lowest safe -# release: 1.82.7 / 1.82.8 had a supply-chain compromise (1.83.0 floor) and +# release: 1.82.7 / 1.82.8 had a supply-chain compromise (1.83.0 floor), # 1.83.0–1.83.9 carry a SQL-injection / SSTI / RCE / sandbox-escape cluster -# (GHSA-r75f-5x8p-qvmc, -xqmj-j6mv-4862, -v4p8-mg3p-g94g, -wxxx-gvqv-xp7p), -# all patched by 1.83.10. -litellm==1.83.10 +# (GHSA-r75f-5x8p-qvmc, -xqmj-j6mv-4862, -v4p8-mg3p-g94g, -wxxx-gvqv-xp7p, +# patched by 1.83.10), and < 1.84.0 carries an authentication bypass via +# Host header injection (GHSA-4xpc-pv4p-pm3w, critical) plus an +# internal_user API-key privilege escalation (GHSA-qrc4-49gv-mv9m, high) — +# both patched by 1.84.0. +litellm==1.84.0 # httpx for the MCP tool call (routed through the sidecar forward proxy). # Bumped to 0.28.1 to satisfy a2a-sdk's httpx>=0.28.1 floor. From 821160feb6326e5ff0745a90b6fd9c7c98e8089e Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 17 Jul 2026 21:26:32 -0400 Subject: [PATCH 18/18] build(cpex): bump CPEX to v0.2.2 and migrate hr demo policy to canonical APL syntax Bumps the CPEX dependency from v0.2.0-alpha.2 to v0.2.2: * CPEX_FFI_VERSION -> v0.2.2 (FFI ABI unchanged, still 2) * github.com/contextforge-org/cpex/go/cpex v0.2.2 in authlib + the authbridge-cpex module (go.mod/go.sum) v0.2.1 renamed the APL authz/authn config keys (breaking): the old 'identity:' and 'policy:' keys are hard load errors now. Migrate the hr-cpex demo policy to the v0.2.2 canonical shape: * routes drop the 'apl:' wrapper -> per-route 'authentication:' + 'authorization.pre_invocation:', with 'args:'/'result:' as siblings * global.identity -> per-route authentication groups * plugin(...) -> run(...) Cedar PDP (cedar-direct), delegate(..., permissions:), taint(), redact(), and inline ': deny(...)' guards are unchanged. The require()-derived deny codes shift from '..._apl_policy_0_' to '..._apl_pre_invocation_0_'; scenarios 02 and 06 expected-code notes updated to match. Also refreshed the cpex plugin + demo READMEs to the canonical syntax. Validated end-to-end on kind-kagenti: rebuilt authbridge-cpex linking the v0.2.2 musl FFI, sidecar parsed the canonical policy cleanly, all 9 demo scenarios pass with identical authorization outcomes. Assisted-By: Claude (Anthropic AI) Signed-off-by: Frederico Araujo --- authbridge/authlib/go.mod | 2 +- authbridge/authlib/go.sum | 4 +- authbridge/authlib/plugins/cpex/README.md | 36 +++--- .../cmd/authbridge-cpex/CPEX_FFI_VERSION | 2 +- authbridge/cmd/authbridge-cpex/go.mod | 2 +- authbridge/cmd/authbridge-cpex/go.sum | 4 +- authbridge/demos/hr-cpex/README.md | 12 +- authbridge/demos/hr-cpex/k8s/cpex-policy.yaml | 106 +++++++++--------- .../demos/hr-cpex/scenarios/02-alice-deny.sh | 2 +- .../hr-cpex/scenarios/06-bob-apl-deny.sh | 4 +- 10 files changed, 93 insertions(+), 81 deletions(-) diff --git a/authbridge/authlib/go.mod b/authbridge/authlib/go.mod index e6aa9e761..4bc3906be 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -3,7 +3,7 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib go 1.26.4 require ( - github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 + github.com/contextforge-org/cpex/go/cpex v0.2.2 github.com/envoyproxy/go-control-plane/envoy v1.37.0 github.com/fsnotify/fsnotify v1.10.1 github.com/gobwas/glob v0.2.3 diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 80ee387b3..bf08884d7 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -58,8 +58,8 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 h1:Ddpq+JFjnA7G4VUiHJPP2520GPmIv2XmikUV2n/gRd0= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.2 h1:rC0HciJa19nEz9LcjmgwyjoRF3EUFidNpAT6eYu1fhA= +github.com/contextforge-org/cpex/go/cpex v0.2.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/authbridge/authlib/plugins/cpex/README.md b/authbridge/authlib/plugins/cpex/README.md index 0898cea81..4a28934ab 100644 --- a/authbridge/authlib/plugins/cpex/README.md +++ b/authbridge/authlib/plugins/cpex/README.md @@ -84,7 +84,7 @@ so resolved credentials reach delegation in CPEX memory. ## CPEX plugins APL routes pull from a toolbox of named CPEX sub-plugins declared in the CPEX -YAML. Routes invoke them by name through APL steps: `plugin(pii-scan)`, +YAML. Routes invoke them by name through APL steps: `run(pii-scan)`, `delegate(workday-oauth, ...)`, `cedar: { ... }`. See the HR demo for examples. ## CPEX APL @@ -96,24 +96,30 @@ sequencing, over the common CMF vocabulary. A route binds a policy to one entity ```yaml routes: - tool: get_compensation - apl: - policy: + authentication: + - jwt-user # identity plugins that resolve the caller + - jwt-client + authorization: + pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" - "taint(secret, session)" # session label persists across calls - - "plugin(audit-log)" - args: - ssn: "str | redact(!perm.view_ssn)" # redact request arg when caller lacks the perm - result: - ssn: "str | redact(!perm.view_ssn)" # redact the response field too + - "run(audit-log)" + args: + ssn: "str | redact(!perm.view_ssn)" # redact request arg when caller lacks the perm + result: + ssn: "str | redact(!perm.view_ssn)" # redact the response field too - tool: send_email - apl: - policy: + authentication: + - jwt-user + - jwt-client + authorization: + pre_invocation: - "require(perm.email_send)" - - "plugin(pii-scan)" + - "run(pii-scan)" - "security.labels contains \"secret\": deny('external email blocked', 'session_tainted_secret')" - - "plugin(audit-log)" + - "run(audit-log)" ``` `get_compensation` taints the session with `secret`. The label persists in the @@ -127,17 +133,17 @@ APL steps can call out to external policy decision points and act on their verdict with the same effect vocabulary: ```yaml -policy: +pre_invocation: - require(authenticated) - opa("http://opa:8181/v1/data/hr/compensation/deny"): on_deny: - deny - taint(compensation_violation, session) - - plugin(audit-log) + - run(audit-log) - cedar: action: "Jans::Action::Read" resource_type: "Jans::CompensationRecord" - on_deny: [deny, plugin(compliance_alert)] + on_deny: [deny, run(compliance_alert)] - authzen("https://authz.corp.com/access/v1/evaluation"): on_deny: [deny] ``` diff --git a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION index b68e0d37c..f0cfd3bb6 100644 --- a/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION @@ -1 +1 @@ -v0.2.0-alpha.2 +v0.2.2 diff --git a/authbridge/cmd/authbridge-cpex/go.mod b/authbridge/cmd/authbridge-cpex/go.mod index 0d866e16f..6805a8055 100644 --- a/authbridge/cmd/authbridge-cpex/go.mod +++ b/authbridge/cmd/authbridge-cpex/go.mod @@ -6,7 +6,7 @@ require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101 require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 // indirect + github.com/contextforge-org/cpex/go/cpex v0.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect diff --git a/authbridge/cmd/authbridge-cpex/go.sum b/authbridge/cmd/authbridge-cpex/go.sum index b45285bb9..ef8bc7008 100644 --- a/authbridge/cmd/authbridge-cpex/go.sum +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -8,8 +8,8 @@ github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcO github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2 h1:Ddpq+JFjnA7G4VUiHJPP2520GPmIv2XmikUV2n/gRd0= -github.com/contextforge-org/cpex/go/cpex v0.2.0-alpha.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= +github.com/contextforge-org/cpex/go/cpex v0.2.2 h1:rC0HciJa19nEz9LcjmgwyjoRF3EUFidNpAT6eYu1fhA= +github.com/contextforge-org/cpex/go/cpex v0.2.2/go.mod h1:SuDkdY76asy9MP/72YKqhc2FBcFZ9t5PKe3CeXSO4/g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/authbridge/demos/hr-cpex/README.md b/authbridge/demos/hr-cpex/README.md index 50499742b..1f4fd03a8 100644 --- a/authbridge/demos/hr-cpex/README.md +++ b/authbridge/demos/hr-cpex/README.md @@ -37,8 +37,8 @@ compose three things a plain PDP does not: cheap predicates first and the PDP only for requests that clear them. - **Explicit effects.** Authorization is more than allow or deny. APL expresses effects as first-class steps: `redact(...)` rewrites a - field on the wire, `plugin(pii-scan)` runs a content guardrail, - `plugin(audit-log)` records the outcome. The decision and what to do + field on the wire, `run(pii-scan)` runs a content guardrail, + `run(audit-log)` records the outcome. The decision and what to do about it live in the same place. - **Actions as decisions.** `delegate(...)` mints a downstream-scoped token (RFC 8693 token exchange) as a policy step, and a post-check @@ -328,9 +328,11 @@ permission directly on the user token. APL gates on a flat predicate and redacts the SSN on the wire when the permission is missing: ```text -require(role.hr) -delegate(workday-oauth, target: workday-api, permissions: [read_compensation]) -plugin(audit-log) +authentication: [jwt-user, jwt-client] +authorization.pre_invocation: + require(role.hr) + delegate(workday-oauth, target: workday-api, permissions: [read_compensation]) + run(audit-log) args.ssn -> str | redact(!perm.view_ssn) result.ssn -> str | redact(!perm.view_ssn) ``` diff --git a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml index 888cc34ec..b4e1b72ca 100644 --- a/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml +++ b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml @@ -20,12 +20,17 @@ # 2 validators (pii-scan, audit-log) # global.apl.pdp: 1 Cedar PDP (relationship-based authorization) # -# routes: get_compensation → ACT 1 · Workday flow -# search_repos → ACT 2 · GitHub flow -# send_email → ACT 3 · PII scanner +# Each route names the identities it needs in its `authentication:` +# block and lists its ordered authorization effects under +# `authorization.pre_invocation:`. Plugins otherwise resolve by name + +# hook, so the grouping above is purely for reading. # -# Plugins resolve by name + hook + priority, NOT by their order in this -# file, so the grouping below is purely for reading. +# Syntax note: this file uses the CPEX v0.2.2 *canonical* APL shape — +# per-route `authentication:` + `authorization:` sibling blocks, no +# `apl:` wrapper, `args:`/`result:` as route-level siblings, and `run(...)` +# to invoke a plugin as an effect (the old `plugin(...)` spelling still +# works as an alias). The pre-0.2.1 `identity:` / `policy:` keys are hard +# errors now. # ──────────────────────────────────────────────────────────────────── # # Keycloak runs locally at http://localhost:8081. JWTs are RS256-signed @@ -40,14 +45,13 @@ plugins: # ── IDENTITY ───────────────────────────────────────────────────── # Two JWT resolvers fire on identity.resolve: the human user's token # (X-User-Token) and the agent's client token (Authorization). Both - # verify against the realm JWKS. + # verify against the realm JWKS. Each route lists the ones it needs in + # its `authentication:` block. # User identity. Carries the human's roles + permissions claims. - name: jwt-user kind: identity/jwt hooks: [identity.resolve] - mode: sequential - priority: 10 on_error: fail config: role: user @@ -69,8 +73,6 @@ plugins: - name: jwt-client kind: identity/jwt hooks: [identity.resolve] - mode: sequential - priority: 20 on_error: fail config: role: client @@ -101,9 +103,9 @@ plugins: - name: workday-oauth kind: delegator/oauth hooks: [token.delegate] - mode: sequential - priority: 30 - on_error: fail + on_error: fail # delegation failure must BLOCK the call — get_compensation + # has no require(delegation.granted) post-check, so without + # this a failed token exchange would fail open. capabilities: - read_inbound_credentials - write_delegated_tokens @@ -130,9 +132,9 @@ plugins: - name: github-oauth kind: delegator/oauth hooks: [token.delegate] - mode: sequential - priority: 31 - on_error: fail + on_error: fail # delegation failure must BLOCK the call. search_repos also + # has a granted-permissions post-check, but fail-closed here + # keeps a transport/exchange error from ever slipping through. capabilities: - read_inbound_credentials - write_delegated_tokens @@ -150,7 +152,7 @@ plugins: # ── VALIDATORS ─────────────────────────────────────────────────── # pii-scan denies on sensitive patterns; audit-log observes and never - # blocks. Routes invoke them via plugin(pii-scan) / plugin(audit-log). + # blocks. Routes invoke them via run(pii-scan) / run(audit-log). # ---------------------------------------------------------------- # Field-level validator: PII scanner. @@ -161,8 +163,6 @@ plugins: - name: pii-scan kind: validator/pii-scan hooks: [cmf.tool_pre_invoke] - mode: sequential - priority: 25 on_error: fail config: detect: @@ -177,15 +177,14 @@ plugins: # client, entity, tool args, and any delegation outcomes. Never # blocks — observation only. Wired on every CMF entity hook so # the audit trail covers all tool / prompt / resource traffic. + # Placed LAST in each route's pre_invocation so the record reflects + # the final decision + minted tokens. - name: audit-log kind: audit/logger hooks: - cmf.tool_pre_invoke - cmf.prompt_pre_invoke - cmf.resource_pre_fetch - mode: sequential - priority: 90 # fires AFTER policy / delegate so the record - # reflects the final decision + minted tokens on_error: ignore # observation failures don't halt traffic capabilities: - read_subject @@ -197,10 +196,6 @@ plugins: source: cpex-demo-gateway global: - identity: - - jwt-user - - jwt-client - # ---------------------------------------------------------------- # Cedar PDP — declarative authorization for relationship-based # decisions. The Cedar policies live inline below. APL routes @@ -254,8 +249,11 @@ routes: # role/perm predicate + redacts ssn on the wire if missing. # ---------------------------------------------------------------- - tool: get_compensation - apl: - policy: + authentication: + - jwt-user + - jwt-client + authorization: + pre_invocation: - "require(role.hr)" - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" # Taint the session: reading compensation marks this session as @@ -267,22 +265,22 @@ routes: - "taint(secret, session)" # Emit a structured audit record AFTER the policy + delegate # decisions are in. Records who called what, with which scope. - - "plugin(audit-log)" - args: - # `redact(!perm.view_ssn)` fires when the caller LACKS the - # permission. The arg key remains, value becomes "[REDACTED]" — - # downstream tool can see "ssn was here but you can't read it." - ssn: "str | redact(!perm.view_ssn)" - result: - # Symmetric guard on the response side. The tool may include - # the SSN in its result (it does, when include_ssn=true); - # without this rule, a caller with role.hr but NOT - # perm.view_ssn (e.g. eve) would receive it. Redacting on the - # response closes the loop — the LLM-driven flow doesn't even - # require the caller to mention `ssn` in args for the guard - # to fire, because the SSN comes back unsolicited in the - # upstream tool's response. - ssn: "str | redact(!perm.view_ssn)" + - "run(audit-log)" + args: + # `redact(!perm.view_ssn)` fires when the caller LACKS the + # permission. The arg key remains, value becomes "[REDACTED]" — + # downstream tool can see "ssn was here but you can't read it." + ssn: "str | redact(!perm.view_ssn)" + result: + # Symmetric guard on the response side. The tool may include + # the SSN in its result (it does, when include_ssn=true); + # without this rule, a caller with role.hr but NOT + # perm.view_ssn (e.g. eve) would receive it. Redacting on the + # response closes the loop — the LLM-driven flow doesn't even + # require the caller to mention `ssn` in args for the guard + # to fire, because the SSN comes back unsolicited in the + # upstream tool's response. + ssn: "str | redact(!perm.view_ssn)" # ════════════════════════════════════════════════════════════════ # ACT 2 · GITHUB FLOW · scenarios 04-alice-internal / 05-external / 06-bob @@ -305,8 +303,11 @@ routes: # forward — the token never reaches GitHub. # ---------------------------------------------------------------- - tool: search_repos - apl: - policy: + authentication: + - jwt-user + - jwt-client + authorization: + pre_invocation: # Layer 1 — coarse APL gate on group membership. - "require(team.engineering | team.security)" @@ -333,7 +334,7 @@ routes: - "!(delegation.granted.permissions contains 'repo:read:internal'): deny" # Audit every github call. - - "plugin(audit-log)" + - "run(audit-log)" # ════════════════════════════════════════════════════════════════ # ACT 3 · PII SCANNER · scenario 07-bob-pii-deny @@ -347,12 +348,15 @@ routes: # via the audit logger. # ---------------------------------------------------------------- - tool: send_email - apl: - policy: + authentication: + - jwt-user + - jwt-client + authorization: + pre_invocation: - "require(perm.email_send)" # Walks args.body / args.subject / args.to and denies if any # carries an SSN-like or credit-card-like pattern. - - "plugin(pii-scan)" + - "run(pii-scan)" # ACT 4 · TAINT PROPAGATION · scenario 08-bob-taint-deny # Block external email from a session that previously accessed # secret data (get_compensation taints the session "secret"). @@ -367,4 +371,4 @@ routes: # yet wired in the pinned FFI — labels surface uniformly under # `security.labels`). Session scope is what makes it survive to here. - "security.labels contains \"secret\": deny('external email blocked: this session accessed secret data', 'session_tainted_secret')" - - "plugin(audit-log)" + - "run(audit-log)" diff --git a/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh index 3dc6837e2..405319820 100755 --- a/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh @@ -14,7 +14,7 @@ set -euo pipefail source "$(dirname "$0")/_lib.sh" step "Alice (engineer) → get_compensation" -note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_get_compensation_apl_policy_0_" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_get_compensation_apl_pre_invocation_0_" note "Triggered by: require(role.hr) deny BEFORE delegation runs" note "Expected upstream: no inbound request (gateway short-circuited)" diff --git a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh index 5dfa1f6b7..9cd616d0e 100755 --- a/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh +++ b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh @@ -14,13 +14,13 @@ # # Result: HTTP 200 with an MCP JSON-RPC 2.0 error frame; error.data.error = # the cpex.* form of the apl.policy step index that failed -# (cpex.routes_tool_search_repos_apl_policy_0_). +# (cpex.routes_tool_search_repos_apl_pre_invocation_0_). set -euo pipefail source "$(dirname "$0")/_lib.sh" step "Bob (HR) → search_repos (gateway short-circuits at the APL gate)" -note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_search_repos_apl_policy_0_" +note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_search_repos_apl_pre_invocation_0_" note "Triggered by: require(team.engineering | team.security) — Bob is team.hr" note "Expected: Cedar never runs; IdP never called"