diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6aaa888cf..a03f552d8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -56,6 +56,16 @@ jobs: build_args: | GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker + # 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 @@ -109,6 +119,27 @@ 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 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 + 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 @@ -120,3 +151,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..599ffe1e7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ Thumbs.db *.bak *.orig +# Local scratch / working notes (never committed) +.sketchpad/ + # venv venv .venv @@ -67,3 +70,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 a65208f3f..a8959520d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,6 +171,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-proxy/Dockerfile` (+ `GO_BUILD_TAGS=exclude_plugin_*`) | proxy-sidecar combined image built auth-only (jwt-validation + token-exchange; OPA + parsers dropped) + spiffe-helper. A build variant of `authbridge`, not a separate binary; 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 + proxy-sidecar enforce-redirect modes) | In all three combined images, `spiffe-helper` is started conditionally diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index e3139eaf5..3c17c8853 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -20,6 +20,12 @@ binaries with shared auth logic in `authlib/`: `-tags include_plugin_contextguru` to link it in. - `cmd/authbridge-envoy/` — envoy-sidecar mode. ext_proc gRPC server hooked into Envoy. Full plugin set. The `exclude_plugin_ibac` tag applies here too. +- `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 + binaries are pure-Go (CGO_ENABLED=0) and do not import the cpex package. - `authbridge-lite` (**image, not a separate binary**) — `cmd/authbridge-proxy` built with `exclude_plugin_*` tags so only jwt-validation + token-exchange compile in (OPA + parsers dropped). For size-optimized deployments that @@ -64,6 +70,12 @@ authbridge/ │ ├── Dockerfile # envoy-sidecar combined image (Envoy + authbridge-envoy) │ └── 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 + proxy-sidecar enforce-redirect modes) │ ├── 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 ba4fb3a6b..4bc3906be 100644 --- a/authbridge/authlib/go.mod +++ b/authbridge/authlib/go.mod @@ -3,6 +3,7 @@ module github.com/kagenti/kagenti-extensions/authbridge/authlib go 1.26.4 require ( + 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 @@ -99,6 +100,8 @@ require ( github.com/valyala/fasthttp v1.71.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/vektah/gqlparser/v2 v2.5.34 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/authbridge/authlib/go.sum b/authbridge/authlib/go.sum index 926aaafeb..bf08884d7 100644 --- a/authbridge/authlib/go.sum +++ b/authbridge/authlib/go.sum @@ -58,6 +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.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= @@ -261,6 +263,10 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.34 h1:MEea5P0qhdcqfBL45ghKE+qr9laidVHTMHjav5h7ckk= github.com/vektah/gqlparser/v2 v2.5.34/go.mod h1:mFdHLGCio7OGX1fby9ZjTW6FN+qxgmbnBcRIeeScE5s= +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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 429ffe744..e1e801a9c 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -289,6 +289,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/README.md b/authbridge/authlib/plugins/cpex/README.md new file mode 100644 index 000000000..4a28934ab --- /dev/null +++ b/authbridge/authlib/plugins/cpex/README.md @@ -0,0 +1,317 @@ +# 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). + +## 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. + +```text +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: `run(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 + 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 + - "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 + authentication: + - jwt-user + - jwt-client + authorization: + pre_invocation: + - "require(perm.email_send)" + - "run(pii-scan)" + - "security.labels contains \"secret\": deny('external email blocked', 'session_tainted_secret')" + - "run(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 +pre_invocation: + - require(authenticated) + - opa("http://opa:8181/v1/data/hr/compensation/deny"): + on_deny: + - deny + - taint(compensation_violation, session) + - run(audit-log) + - cedar: + action: "Jans::Action::Read" + resource_type: "Jans::CompensationRecord" + on_deny: [deny, run(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. + +> **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. 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 + +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: + +```text +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 (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 | + +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 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 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 + +```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/authlib/plugins/cpex/cmf_a2a.go b/authbridge/authlib/plugins/cpex/cmf_a2a.go new file mode 100644 index 000000000..e6bb3ac27 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_a2a.go @@ -0,0 +1,234 @@ +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 + } + // 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" { + if t, ok := po["text"].(string); ok && t != "" { + 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 single artifact text part with +// newArtifact (the redacted text CPEX returned on the response phase). +// +// 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. +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 + } + + // 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 { + 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 t, ok := po["text"].(string); ok && t != "" { + targets = append(targets, po) + } + } + } + + 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 +// 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..0ec5e60c2 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_a2a_test.go @@ -0,0 +1,184 @@ +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) + } +} + +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) { + 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) + } + // 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) { + // 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"), + } + 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..67f731a26 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_body.go @@ -0,0 +1,630 @@ +package cpex + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + + "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 +// 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 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) + 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 +// 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 +// +// 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 +// 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": + // 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.URISet { + 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. +// +// 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. +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 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" { + 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 + // 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..111e4f2b7 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_body_test.go @@ -0,0 +1,700 @@ +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, ArgsSet: true}) + 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, ArgsSet: true}) + 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", URISet: true}) + 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_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{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if mutated { + 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}, ArgsSet: true}) + 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}, ArgsSet: true}) + 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}, ArgsSet: true}) + 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}, ArgsSet: true}) + 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_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"}}}`), + } + 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) + } +} + +// --- 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/cmf_inference.go b/authbridge/authlib/plugins/cpex/cmf_inference.go new file mode 100644 index 000000000..f06c46af8 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_inference.go @@ -0,0 +1,220 @@ +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 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. +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 + } + + // 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 { + continue + } + msg, ok := choice["message"].(map[string]any) + if !ok { + continue + } + if c, ok := msg["content"].(string); ok && c != "" { + targets = append(targets, msg) + } + } + 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 { + 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..7e48cd822 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/cmf_inference_test.go @@ -0,0 +1,206 @@ +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_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"), + } + 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..52702ff6e --- /dev/null +++ b/authbridge/authlib/plugins/cpex/headers.go @@ -0,0 +1,126 @@ +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 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 +// 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 (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 + } + 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..46d7c3108 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager.go @@ -0,0 +1,175 @@ +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/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. +// +// 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 ( + // 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 + + // 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 DecisionUnknown: + return "unknown" + 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..89bfc8788 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager_cpex.go @@ -0,0 +1,892 @@ +//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(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() { + 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) + + // 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 + // 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 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. +// +// 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. +// - 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, inference, and A2A body modifications are each +// re-serialized via format-aware write-back functions (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 { + // 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 + } + 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 (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; +// 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..df5d7b891 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/manager_test.go @@ -0,0 +1,125 @@ +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 DecisionDeny (fail-closed default for test fidelity). + 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 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() + 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: DecisionDeny, Reason: "fake: no hook configured (deny by default)"}, nil +} diff --git a/authbridge/authlib/plugins/cpex/plugin.go b/authbridge/authlib/plugins/cpex/plugin.go new file mode 100644 index 000000000..fc62d96e0 --- /dev/null +++ b/authbridge/authlib/plugins/cpex/plugin.go @@ -0,0 +1,480 @@ +// 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 + + // 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 + } + 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} + } + // 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} + } + + 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. 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", + "cpex invocation failed", + ) +} + +// 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} + } + // 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", + "cpex decision handling failed", + ) +} + +// 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..767ba796e --- /dev/null +++ b/authbridge/authlib/plugins/cpex/plugin_test.go @@ -0,0 +1,713 @@ +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 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 + 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_HostBypassSkipsInvokeOutbound(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. 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{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) + } + if len(fake.Invokes) != 0 { + t.Fatalf("Invoke called %d times, want 0 (host bypass should skip FFI)", len(fake.Invokes)) + } +} + +// 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}, + 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 2e429acd4..0d0337133 100644 --- a/authbridge/authlib/plugins/tokenexchange/plugin.go +++ b/authbridge/authlib/plugins/tokenexchange/plugin.go @@ -8,7 +8,9 @@ import ( "fmt" "log/slog" "net/http" + "strings" "sync/atomic" + "time" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" @@ -701,6 +703,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" @@ -762,6 +765,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/abctl/go.mod b/authbridge/cmd/abctl/go.mod index 9cc9cc110..5e72c653d 100644 --- a/authbridge/cmd/abctl/go.mod +++ b/authbridge/cmd/abctl/go.mod @@ -49,10 +49,11 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/authbridge/cmd/abctl/go.sum b/authbridge/cmd/abctl/go.sum index da0e01199..d4e6d355e 100644 --- a/authbridge/cmd/abctl/go.sum +++ b/authbridge/cmd/abctl/go.sum @@ -136,8 +136,7 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqn github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -171,8 +170,7 @@ go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSY go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -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/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= 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/CPEX_FFI_VERSION b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION new file mode 100644 index 000000000..f0cfd3bb6 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION @@ -0,0 +1 @@ +v0.2.2 diff --git a/authbridge/cmd/authbridge-cpex/Dockerfile b/authbridge/cmd/authbridge-cpex/Dockerfile new file mode 100644 index 000000000..d447b9ac9 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/Dockerfile @@ -0,0 +1,165 @@ +# 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 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) +# 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 + 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 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 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} +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"; \ + 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 \ + echo "[ffi] sha256 mismatch: expected $expected_hash, got $actual_hash" >&2; \ + 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; }; \ + # 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: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.26-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder + +RUN apk add --no-cache git ca-certificates gcc musl-dev + +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 +# 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 -trimpath -ldflags="-s -w" -o /authbridge-cpex . + +# ---------- Stage 3: runtime ---------------------------------------- +# 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 + +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..7a54c86bd --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# 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..6805a8055 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/go.mod @@ -0,0 +1,41 @@ +module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-cpex + +go 1.26.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.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 + 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.6 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // 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.7 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // 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..ef8bc7008 --- /dev/null +++ b/authbridge/cmd/authbridge-cpex/go.sum @@ -0,0 +1,173 @@ +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/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ= +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.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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-sqlbuilder v1.41.0 h1:oWa6DOKkyRf/oYWrAA5tyEyolDJg39oTTf0+FYy94/E= +github.com/huandu/go-sqlbuilder v1.41.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc= +github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +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/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +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.7 h1:bnYeET+S8IOyAw6W4LTc6SEeK7Xs58SKKZkR7scb3Ko= +github.com/lestrrat-go/jwx/v2 v2.1.7/go.mod h1:exQ9ZBuN1cMLYmxwhTlHUru08ykONG0z+HbLEeDG9qo= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +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/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +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.18.2 h1:VBiLJpioTuk7XTW1JoQi4ILo+FVxD2/8uD8iP9/OcxY= +github.com/open-policy-agent/opa v1.18.2/go.mod h1:9GY+hER4ZEXtxPlMjftVbqJJY9xLtCD3Q0oufRCfAKo= +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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vektah/gqlparser/v2 v2.5.34 h1:MEea5P0qhdcqfBL45ghKE+qr9laidVHTMHjav5h7ckk= +github.com/vektah/gqlparser/v2 v2.5.34/go.mod h1:mFdHLGCio7OGX1fby9ZjTW6FN+qxgmbnBcRIeeScE5s= +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/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +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-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +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.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 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 4e3f108e9..90b31cb6d 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..369538758 --- /dev/null +++ b/authbridge/demos/hr-cpex/Makefile @@ -0,0 +1,192 @@ +# CPEX demo Makefile — kind-cluster flow. +# +# End-to-end: +# +# make deploy # build images, load into kind, kubectl apply +# 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/). + +KIND_CLUSTER ?= kagenti +# 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. +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 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 + +# 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 +# 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 + +.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; } + @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 + agent 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:]') \ + --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) . + @echo "[build] hr-cpex-agent from $(AGENT_SRC)" + cd $(AGENT_SRC) && docker build -t $(AGENT_IMAGE) . + +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 + # 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-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 + @# 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" + @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) + agent (8082) + fwd proxy (8083) + session API (9094) + @echo "Port-forwarding (Ctrl+C to stop)..." + @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/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 \ + PROXY=http://localhost:8083 \ + KEYCLOAK_HOST=http://localhost:8081 \ + "$$s"; \ + done + +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 new file mode 100644 index 000000000..1f4fd03a8 --- /dev/null +++ b/authbridge/demos/hr-cpex/README.md @@ -0,0 +1,455 @@ +# CPEX Bridge Demo + +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 +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. + +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: + +- **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, `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 + 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. + +## 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 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`) — 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, 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. + +[kagenti]: https://github.com/kagenti/kagenti + +## Quick start + +```bash +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` 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: + +```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 +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` +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 → 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 +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. + +## 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, 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. + +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 +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-client.txt +``` + +Run it (the A2A endpoint is the agent Service, exposed on `:8082` by +`make port-forward`): + +```bash +python chat.py --persona eve +# or point at a different endpoint: +AGENT_URL=http://localhost:8082 python chat.py --persona bob +``` + +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 +# 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: + +```text +switch swap persona and re-mint both tokens (alice, bob, eve) +relogin refresh both tokens if they expire mid-demo +quit exit +``` + +Suggested conversation: + +```text +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 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. + +```text +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: forward proxy + | token exchange | to the MCP target + 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. + +**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 +`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: + +```text +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) +``` + +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/hr-cpex-agent +``` + +`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-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/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 + +```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 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, + 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. +- **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/CHAT-WALKTHROUGH.md b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md new file mode 100644 index 000000000..bee18c060 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md @@ -0,0 +1,294 @@ +# 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. + +`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: + +```bash +# from authbridge/demos/hr-cpex +make deploy # builds images, loads to kind, applies manifests +``` + +In a second terminal: + +```bash +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): + +```bash +kubectl -n cpex-demo logs -f deploy/hr-mcp +``` + +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). +python chat.py --persona bob # or: AGENT_URL=http://localhost:8082 python chat.py --persona bob +``` + +chat.py writes a banner showing persona + agent endpoint + session. +You're now talking to the agent over A2A; CPEX governs its egress. + +--- + +## 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 `-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) + +**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 `-32000`, + `data.error = "cpex.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 `-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 +`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 `-32000`, + `data.error = "cpex.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 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", + "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 `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. + +--- + +## 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 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..7acf14e94 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/Dockerfile @@ -0,0 +1,28 @@ +# 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. + +# 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 + +# 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..e575bb5dd --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/agent.py @@ -0,0 +1,572 @@ +#!/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 threading +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, Message, Part, Role, TextPart +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: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. +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 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 = ( + "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" + "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" + " * 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. " + "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 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) → deny. The + # request-side args.ssn redaction is still exercised by the + # deterministic curl matrix (scenarios/01-bob-allow.sh). + }, + "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: + # 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("error"), + } + ) + 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. + 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): + 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 + # 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]]: + 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, + user_text: str, + *, + user_token: str, + client_token: str, + session_id: str, + ) -> 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}) + + # 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 + + 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. + tool_trace: list[dict[str, Any]] = [] + 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 = {} + request_id = self._next_request_id() + 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=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}) + tool_trace.append({"name": fn.name, "args": args, "status": status, "text": 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, tool_trace + + +# --------------------------------------------------------------------------- +# 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, tool_trace = await asyncio.to_thread( + self._agent.run_turn, + user_text, + user_token=user_token, + client_token=authorization, + session_id=session_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. + 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 new file mode 100755 index 000000000..eeadda3ea --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/chat.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +# 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-client.txt + python chat.py --persona bob + +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 asyncio +import json +import os +import sys +import uuid + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.client.middleware import ClientCallContext, ClientCallInterceptor +from a2a.types import ( + AgentCapabilities, + AgentCard, + Message, + Part, + Role, + Task, + TextPart, +) +from a2a.utils import get_message_text +from rich.console import Console +from rich.markup import escape +from rich.panel import Panel + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +# 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" +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", + }, +} + + +# --------------------------------------------------------------------------- +# 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.""" + 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 agent 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 + + +# --------------------------------------------------------------------------- +# A2A session +# --------------------------------------------------------------------------- + + +def new_session_id(persona: str) -> str: + """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 _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, + *, + user_token: str, + client_token: str, + session_id: str, +) -> 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: + 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 "" + + +# --------------------------------------------------------------------------- +# Chat loop +# --------------------------------------------------------------------------- + + +def run_chat(persona: str, agent_url: str, keycloak_host: str, show_tools: bool = False) -> 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 port-forwarded? `make port-forward` exposes it on :8081.[/dim]") + return + + session_id = new_session_id(persona) + + console.print() + console.print( + Panel( + f"[bold]{info['name']}[/bold] — {info['title']}\n" + f"[dim]{info['description']}[/dim]\n\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 — A2A client[/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" + ) + + 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 + # 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: + 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 + 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: + 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 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) + 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 + + try: + reply, tool_trace = asyncio.run( + send_turn( + agent_url, + user_input, + user_token=user_tok, + client_token=client_tok, + session_id=session_id, + ) + ) + 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") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> int: + p = argparse.ArgumentParser(description="A2A client for the hr-cpex agent") + p.add_argument( + "--persona", + default="alice", + choices=list(PERSONAS), + help="Starting persona (switch in-session with `switch `)", + ) + p.add_argument( + "--agent", + default=os.environ.get("AGENT_URL", DEFAULT_AGENT), + help=f"Agent A2A endpoint (default: {DEFAULT_AGENT})", + ) + p.add_argument( + "--keycloak", + 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, show_tools=args.show_tools) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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 new file mode 100644 index 000000000..c89ee9819 --- /dev/null +++ b/authbridge/demos/hr-cpex/agent/requirements.txt @@ -0,0 +1,31 @@ +# 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. + +# 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 + +# 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 (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, +# 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. +httpx==0.28.1 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..824c5f73a --- /dev/null +++ b/authbridge/demos/hr-cpex/hr-mcp-server/requirements.txt @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 000000000..e33f1dfd6 --- /dev/null +++ b/authbridge/demos/hr-cpex/hr-mcp-server/server.py @@ -0,0 +1,295 @@ +"""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 + + +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() + + # 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", _log_safe(rpc.get("method"))) + params = rpc.get("params", {}) + 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( + {"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: + # 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": "internal tool error"}, + "id": rpc_id, + }, + status_code=500, + ) + + logger.info("OUTBOUND RESPONSE") + logger.info(" tool = %s", _log_safe(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..e55110d93 --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/10-keycloak.yaml @@ -0,0 +1,130 @@ +# 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. +# +# 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 +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 + # 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" + 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..e2fa99f17 --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/20-hr-mcp.yaml @@ -0,0 +1,72 @@ +# 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 + # 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 + # 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-agent.yaml b/authbridge/demos/hr-cpex/k8s/30-agent.yaml new file mode 100644 index 000000000..30b0f2c5c --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/30-agent.yaml @@ -0,0 +1,267 @@ +# 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. + +--- +# 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 +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 + # 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" + # 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:8b" + - 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 + # 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" + 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 + # 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. + 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 + - 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 new file mode 100644 index 000000000..b4e1b72ca --- /dev/null +++ b/authbridge/demos/hr-cpex/k8s/cpex-policy.yaml @@ -0,0 +1,374 @@ +# CPEX runtime config for the HR demo. +# +# 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, +# 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) +# +# 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. +# +# 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 +# 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. 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] + 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] + 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] + 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 + 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: file + path: /etc/cpex-secrets/client-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] + 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 + 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: file + path: /etc/cpex-secrets/client-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 run(pii-scan) / run(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] + 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. + # 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 + 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: + # ---------------------------------------------------------------- + # 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 + 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 + # 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. + - "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 + # ════════════════════════════════════════════════════════════════ + # 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 + authentication: + - jwt-user + - jwt-client + authorization: + pre_invocation: + # 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. + - "run(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 + 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. + - "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"). + # 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')" + - "run(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..405319820 --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Alice (engineer, role.engineer) calls get_compensation. Expected: +# +# * 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) + +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_pre_invocation_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..9ff1564ef --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/04-alice-internal-allow.sh @@ -0,0 +1,45 @@ +#!/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) + +# 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" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "search_repos", + "arguments": { "repo_name": "web-app", "visibility": "internal" } + } + }') +printf '%s\n' "$resp" | jq . 2>/dev/null || printf '%s\n' "$resp" 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..e6c1063c7 --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh @@ -0,0 +1,41 @@ +#!/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 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 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)" + +ALICE=$(mint alice) +CLIENT=$(mint hr-copilot) + +curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ + -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..9cd616d0e --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh @@ -0,0 +1,46 @@ +#!/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 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_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_pre_invocation_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) + +# 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" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "search_repos", + "arguments": { "visibility": "internal" } + } + }' 2>&1) +printf '%s\n' "$resp" | sed -n '1,20p' 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..4901bd501 --- /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 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 +# 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 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)" + +BOB=$(mint bob) +CLIENT=$(mint hr-copilot) + +curl -s -x "$PROXY" -X POST "$MCP_TARGET" \ + -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..024c83772 --- /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 → 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 + +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 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 new file mode 100755 index 000000000..ba61787db --- /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 been denied. +# +# 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" + +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 denied (cpex.session_tainted_secret)." +SESSION_ID="$SID_SHARED" 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..b4b46b32f --- /dev/null +++ b/authbridge/demos/hr-cpex/scenarios/_lib.sh @@ -0,0 +1,127 @@ +# shellcheck shell=bash +# Shared helpers for the scenario scripts. Source from each script: +# +# source "$(dirname "$0")/_lib.sh" + +# 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() { + "$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 "$PROXY" -X POST "$MCP_TARGET" \ + -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` 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_match" = "true" ]; then + ok "cpex-gateway → workday-api" "OK (aud=$aud)" +else + fail "cpex-gateway → workday-api" "aud mismatch: expected to contain '$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..bc12f3b72 --- /dev/null +++ b/authbridge/docs/cpex-plugin.md @@ -0,0 +1,352 @@ +# 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 +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. + +```text +┌──────────────────────────────────────────────────────────────────┐ +│ 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 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 | +|---|---|---| +| `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 (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. | +| `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 / 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 + +`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 + +- **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 + 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 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 + +- `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 61a30733b..ccedcd8bb 100644 --- a/authbridge/go.work +++ b/authbridge/go.work @@ -3,6 +3,7 @@ go 1.26.4 use ( ./authlib ./cmd/abctl + ./cmd/authbridge-cpex ./cmd/authbridge-envoy ./cmd/authbridge-proxy ) diff --git a/docs/proposals/authbridge-hooks.md b/docs/proposals/authbridge-hooks.md new file mode 100644 index 000000000..3a01129f5 --- /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.