From 900791af98fc2cacccd74941e6835971828db5f3 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 8 May 2026 22:29:39 -0400 Subject: [PATCH 1/7] feat(pipeline): Capability split for body mutation (ReadsBody / WritesBody) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginCapabilities.BodyAccess was a single flag meaning "I want the body buffered." Splits into two booleans so the framework can distinguish read-only observers (today's parsers) from would-be mutators: ReadsBody — listener buffers body; plugin reads pctx.Body WritesBody — plugin may call pctx.SetBody / pctx.SetResponseBody; listener propagates the mutation to the wire BodyAccess stays as a deprecated alias, folded into ReadsBody by a new Normalize() helper. Dropped in the next release. pipeline.New validates two body-mutation rules: - At most one WritesBody plugin per pipeline (mutation ordering would be ambiguous; the error names both plugins so an operator reading pod logs can identify which two to reconcile). - A WritesBody plugin cannot precede a ReadsBody plugin — readers must see the original bytes, not a rewrite. Pipeline.NeedsBody now checks ReadsBody || WritesBody so buffering kicks in for pure mutators too. New Pipeline.WritesBody() lets listeners fast-path the no-mutator case. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/pipeline/pipeline.go | 49 ++++++++++++++++++-- authbridge/authlib/pipeline/plugin.go | 60 ++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/authbridge/authlib/pipeline/pipeline.go b/authbridge/authlib/pipeline/pipeline.go index 51fe5c18d..8e89aa36b 100644 --- a/authbridge/authlib/pipeline/pipeline.go +++ b/authbridge/authlib/pipeline/pipeline.go @@ -177,10 +177,28 @@ func (p *Pipeline) NotReadyPlugin() string { return "" } -// NeedsBody returns true if any plugin in the pipeline declares BodyAccess. +// NeedsBody returns true if any plugin in the pipeline needs the body +// buffered — either to read it (ReadsBody) or to mutate it (WritesBody). +// Normalize() folds the deprecated BodyAccess alias into ReadsBody, so +// both legacy and modern plugins are covered by the single check. func (p *Pipeline) NeedsBody() bool { for _, plugin := range p.plugins { - if plugin.Capabilities().BodyAccess { + caps := plugin.Capabilities().Normalize() + if caps.ReadsBody || caps.WritesBody { + return true + } + } + return false +} + +// WritesBody returns true if any plugin in the pipeline declares +// WritesBody. Listeners use this to decide whether to diff-and-emit a +// body mutation on the wire. A pipeline with no WritesBody plugins +// bypasses the mutation path entirely — zero overhead for the common +// read-only case. +func (p *Pipeline) WritesBody() bool { + for _, plugin := range p.plugins { + if plugin.Capabilities().Normalize().WritesBody { return true } } @@ -259,11 +277,19 @@ func (p *Pipeline) Stop(ctx context.Context) { } // validateCapabilities checks that every slot a plugin reads has been written -// by an earlier plugin in the chain. +// by an earlier plugin in the chain, and applies the body-mutation rules: +// - At most one WritesBody plugin per pipeline (direction-scoped). +// Mutation ordering would otherwise be ambiguous; downstream readers +// can't tell which version they're seeing. +// - A body mutator must not run before a body reader. Readers that +// declared ReadsBody expect to see the original bytes; placing a +// mutator earlier would silently change what they observe. func validateCapabilities(plugins []Plugin, validSlots map[string]bool) error { written := make(map[string]bool) + var mutatorName string // set once the first WritesBody plugin is seen + var readerAfterMutator string // non-empty if a ReadsBody plugin follows the mutator for _, plugin := range plugins { - caps := plugin.Capabilities() + caps := plugin.Capabilities().Normalize() for _, slot := range caps.Reads { if !validSlots[slot] { return fmt.Errorf("plugin %q declares read on unknown slot %q", plugin.Name(), slot) @@ -278,6 +304,21 @@ func validateCapabilities(plugins []Plugin, validSlots map[string]bool) error { } written[slot] = true } + if caps.WritesBody { + if mutatorName != "" { + return fmt.Errorf("pipeline: two plugins declare WritesBody: %q and %q — mutation ordering would be ambiguous; at most one body mutator per pipeline is allowed", mutatorName, plugin.Name()) + } + mutatorName = plugin.Name() + } else if caps.ReadsBody && mutatorName != "" && readerAfterMutator == "" { + // ReadsBody-only plugin running AFTER a WritesBody plugin + // would see the mutated bytes, which surprises the reader. + // Stash the first occurrence; validated below so the error + // names both plugins involved. + readerAfterMutator = plugin.Name() + } + } + if readerAfterMutator != "" { + return fmt.Errorf("pipeline: plugin %q reads body after mutator %q — body readers must precede the mutator so they see the original bytes", readerAfterMutator, mutatorName) } return nil } diff --git a/authbridge/authlib/pipeline/plugin.go b/authbridge/authlib/pipeline/plugin.go index 82fcbc223..f1d0137fa 100644 --- a/authbridge/authlib/pipeline/plugin.go +++ b/authbridge/authlib/pipeline/plugin.go @@ -10,13 +10,61 @@ type Plugin interface { OnResponse(ctx context.Context, pctx *Context) Action } -// PluginCapabilities declares what extension slots a plugin reads and writes. -// The pipeline validates at startup that all reads are satisfied by an earlier -// plugin's writes. +// PluginCapabilities declares what extension slots a plugin reads and +// writes, plus whether it accesses the request / response body. +// +// The pipeline validates at startup that all Reads are satisfied by an +// earlier plugin's Writes. Body-access fields drive the listener's +// body-buffering handshake (ext_proc ProcessingMode, net/http read-body). type PluginCapabilities struct { - Reads []string // extension slot names this plugin reads - Writes []string // extension slot names this plugin writes - BodyAccess bool // whether this plugin needs request/response body buffered + // Reads / Writes name extension slots (A2A, MCP, Inference, Custom + // map keys). Checked at pipeline.New. + Reads []string + Writes []string + + // ReadsBody: the plugin reads pctx.Body in OnRequest and/or + // pctx.ResponseBody in OnResponse. The listener buffers the body + // when any plugin declares this; without it, pctx.Body is nil and + // a read silently sees "no body." + ReadsBody bool + + // WritesBody: the plugin may mutate pctx.Body / pctx.ResponseBody + // (call pctx.SetBody / pctx.SetResponseBody). Implies ReadsBody — + // Normalize() auto-promotes. Listener propagates the mutation to + // the wire (ext_proc BodyMutation, or the outbound http.Request / + // downstream http.Response for proxy listeners). + // + // Pipeline.New rejects a pipeline that has more than one WritesBody + // plugin per direction — mutation ordering would be ambiguous. + // Waypoint mode (ext_authz) cannot support WritesBody at all: + // ext_authz has no body-mutation field. main.go enforces this at + // process boot. + WritesBody bool + + // BodyAccess is a deprecated alias for ReadsBody, kept so existing + // plugins compile unchanged through one release. Normalize() folds + // BodyAccess into ReadsBody before validation and listener + // negotiation read the normalized fields. + // + // Deprecated: use ReadsBody. Will be removed in a future release. + BodyAccess bool +} + +// Normalize applies compatibility rules to a PluginCapabilities: +// - BodyAccess (deprecated) is folded into ReadsBody. +// - WritesBody implies ReadsBody (you can't mutate what you didn't see). +// +// Called by Pipeline.New for every plugin's declared capabilities so the +// rest of the framework reads a normalized form. Plugins never need to +// call this themselves. +func (c PluginCapabilities) Normalize() PluginCapabilities { + if c.BodyAccess { + c.ReadsBody = true + } + if c.WritesBody { + c.ReadsBody = true + } + return c } // Initializer is an optional interface a plugin may implement when it From b34b6af9a8ffa5d1640b14fd3041eedfc30e988e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 8 May 2026 22:29:56 -0400 Subject: [PATCH 2/7] feat(pipeline): pctx.SetBody / SetResponseBody helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins with WritesBody: true use these to mutate the body; the listener reads pctx.BodyMutated() / ResponseBodyMutated() after Run / RunResponse to decide whether to emit a mutation on the wire. SetBody / SetResponseBody auto-emit a modify-action Invocation (Reason: "body_rewritten") and publish a plugin-public event under "body-mutation/event" carrying phase, plugin name, length before/after, and sha256 before/after. Never includes the raw body bytes — the session store has no auth, so raw bodies would be a privacy / credential leak. The flags (not byte-diff) are the source of truth: a rewrite that produces byte-identical output still records the Invocation because "redactor ran, nothing matched" is valid telemetry. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../authlib/pipeline/bodymutation_test.go | 241 ++++++++++++++++++ authbridge/authlib/pipeline/context.go | 100 +++++++- 2 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 authbridge/authlib/pipeline/bodymutation_test.go diff --git a/authbridge/authlib/pipeline/bodymutation_test.go b/authbridge/authlib/pipeline/bodymutation_test.go new file mode 100644 index 000000000..9949bd9f8 --- /dev/null +++ b/authbridge/authlib/pipeline/bodymutation_test.go @@ -0,0 +1,241 @@ +package pipeline + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +// TestCapabilities_Normalize covers the compatibility rules: deprecated +// BodyAccess folds into ReadsBody, and WritesBody auto-promotes to +// ReadsBody so a mutator always satisfies the "must have read" invariant. +func TestCapabilities_Normalize(t *testing.T) { + tests := []struct { + name string + in PluginCapabilities + wantReads bool + wantWrites bool + }{ + { + name: "BodyAccess alias folds into ReadsBody", + in: PluginCapabilities{BodyAccess: true}, + wantReads: true, + }, + { + name: "WritesBody implies ReadsBody", + in: PluginCapabilities{WritesBody: true}, + wantReads: true, + wantWrites: true, + }, + { + name: "explicit ReadsBody passes through", + in: PluginCapabilities{ReadsBody: true}, + wantReads: true, + wantWrites: false, + }, + { + name: "empty is empty", + in: PluginCapabilities{}, + wantReads: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.in.Normalize() + if got.ReadsBody != tc.wantReads { + t.Errorf("ReadsBody = %v, want %v", got.ReadsBody, tc.wantReads) + } + if got.WritesBody != tc.wantWrites { + t.Errorf("WritesBody = %v, want %v", got.WritesBody, tc.wantWrites) + } + }) + } +} + +// TestPipeline_NeedsBody_IncludesWritesBody: NeedsBody returns true even +// if the only body-touching plugin is a pure mutator. Listeners rely on +// this to turn on buffering before the mutator sees (and rewrites) the +// body. +func TestPipeline_NeedsBody_IncludesWritesBody(t *testing.T) { + p := mustBuild(t, &stubPlugin{ + name: "mutator", + caps: PluginCapabilities{WritesBody: true}, + }) + if !p.NeedsBody() { + t.Error("NeedsBody should be true when any plugin declares WritesBody") + } + if !p.WritesBody() { + t.Error("WritesBody should be true") + } +} + +// TestNew_RejectsTwoMutators: two WritesBody plugins in one pipeline +// have ambiguous mutation ordering; Pipeline.New rejects the build and +// the error names both plugins so an operator reading pod logs can +// identify which two to reconcile. +func TestNew_RejectsTwoMutators(t *testing.T) { + _, err := New([]Plugin{ + &stubPlugin{name: "redactor-a", caps: PluginCapabilities{WritesBody: true}}, + &stubPlugin{name: "redactor-b", caps: PluginCapabilities{WritesBody: true}}, + }) + if err == nil { + t.Fatal("expected error for two WritesBody plugins") + } + if !strings.Contains(err.Error(), "redactor-a") || !strings.Contains(err.Error(), "redactor-b") { + t.Errorf("error should name both plugins, got %q", err.Error()) + } +} + +// TestNew_RejectsReaderAfterMutator: a parser that expects to see the +// original bytes must not run after a mutator. The validator catches +// the swapped order at build time instead of silently giving the +// reader mutated content. +func TestNew_RejectsReaderAfterMutator(t *testing.T) { + _, err := New([]Plugin{ + &stubPlugin{name: "rewriter", caps: PluginCapabilities{WritesBody: true}}, + &stubPlugin{name: "parser", caps: PluginCapabilities{ReadsBody: true}}, + }) + if err == nil { + t.Fatal("expected error for reader after mutator") + } + if !strings.Contains(err.Error(), "parser") || !strings.Contains(err.Error(), "rewriter") { + t.Errorf("error should name both plugins, got %q", err.Error()) + } +} + +// TestNew_AcceptsReaderBeforeMutator: the canonical ordering. Parser +// sees the original; mutator sees whatever the parser did, rewrites, +// and the listener ships the rewritten bytes. +func TestNew_AcceptsReaderBeforeMutator(t *testing.T) { + _, err := New([]Plugin{ + &stubPlugin{name: "parser", caps: PluginCapabilities{ReadsBody: true}}, + &stubPlugin{name: "rewriter", caps: PluginCapabilities{WritesBody: true}}, + }) + if err != nil { + t.Fatalf("reader-before-mutator should be valid, got %v", err) + } +} + +// TestContext_SetBody_FlipsFlagAndEmitsInvocation: SetBody is the only +// sanctioned way to mutate pctx.Body; it must (a) actually replace the +// bytes, (b) flip BodyMutated so the listener knows to propagate, and +// (c) record a modify-action Invocation with Reason "body_rewritten". +func TestContext_SetBody_FlipsFlagAndEmitsInvocation(t *testing.T) { + c := &Context{ + Direction: Inbound, + Body: []byte("original"), + } + c.SetCurrentPlugin("redactor", InvocationPhaseRequest) + + c.SetBody([]byte("redacted")) + + if string(c.Body) != "redacted" { + t.Errorf("Body = %q, want redacted", c.Body) + } + if !c.BodyMutated() { + t.Error("BodyMutated should be true after SetBody") + } + if c.ResponseBodyMutated() { + t.Error("ResponseBodyMutated should remain false") + } + + inv := c.Extensions.Invocations + if inv == nil || len(inv.Inbound) != 1 { + t.Fatalf("expected 1 inbound invocation, got %+v", inv) + } + got := inv.Inbound[0] + if got.Action != ActionModify { + t.Errorf("Action = %v, want modify", got.Action) + } + if got.Reason != "body_rewritten" { + t.Errorf("Reason = %q, want body_rewritten", got.Reason) + } + if got.Plugin != "redactor" { + t.Errorf("Plugin = %q, want redactor (framework attribution)", got.Plugin) + } +} + +// TestContext_SetBody_EmitsCustomEvent: the body-mutation/event custom +// entry must (a) land under the framework-owned key, (b) carry length +// delta + sha256 before/after, (c) never carry the raw body bytes. +func TestContext_SetBody_EmitsCustomEvent(t *testing.T) { + c := &Context{Direction: Inbound, Body: []byte("original-payload")} + c.SetCurrentPlugin("redactor", InvocationPhaseRequest) + + c.SetBody([]byte("redacted")) + + raw, ok := c.Extensions.Custom["body-mutation"+PluginEventSuffix] + if !ok { + t.Fatalf("expected body-mutation event in Custom map; got keys: %v", keys(c.Extensions.Custom)) + } + ev, ok := raw.(bodyMutationEvent) + if !ok { + t.Fatalf("event type = %T, want bodyMutationEvent", raw) + } + if ev.Phase != "request" { + t.Errorf("Phase = %q, want request", ev.Phase) + } + if ev.Plugin != "redactor" { + t.Errorf("Plugin = %q, want redactor", ev.Plugin) + } + if ev.LengthBefore != len("original-payload") { + t.Errorf("LengthBefore = %d, want %d", ev.LengthBefore, len("original-payload")) + } + if ev.LengthAfter != len("redacted") { + t.Errorf("LengthAfter = %d, want %d", ev.LengthAfter, len("redacted")) + } + // Verify sha256 is a real hash of the raw bytes, not garbage. + wantBefore := sha256.Sum256([]byte("original-payload")) + if ev.SHA256Before != hex.EncodeToString(wantBefore[:]) { + t.Errorf("SHA256Before hash mismatch") + } + wantAfter := sha256.Sum256([]byte("redacted")) + if ev.SHA256After != hex.EncodeToString(wantAfter[:]) { + t.Errorf("SHA256After hash mismatch") + } +} + +// TestContext_SetResponseBody_PhaseLabel: response-side mutation +// reports Phase "response" in the custom event so operators can tell +// request-side redactions (prompt sanitization) from response-side +// redactions (LLM output filtering) in abctl. +func TestContext_SetResponseBody_PhaseLabel(t *testing.T) { + c := &Context{ + Direction: Outbound, + ResponseBody: []byte("llm completion"), + } + c.SetCurrentPlugin("llm-guardrail", InvocationPhaseResponse) + + c.SetResponseBody([]byte("[redacted]")) + + if !c.ResponseBodyMutated() { + t.Error("ResponseBodyMutated should be true") + } + if c.BodyMutated() { + t.Error("BodyMutated (request side) should remain false") + } + ev := c.Extensions.Custom["body-mutation"+PluginEventSuffix].(bodyMutationEvent) + if ev.Phase != "response" { + t.Errorf("Phase = %q, want response", ev.Phase) + } +} + +// --- helpers -------------------------------------------------------- + +func mustBuild(t *testing.T, ps ...Plugin) *Pipeline { + t.Helper() + p, err := New(ps) + if err != nil { + t.Fatalf("pipeline.New: %v", err) + } + return p +} + +func keys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 842183a1d..7e7c3b185 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -1,6 +1,8 @@ package pipeline import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "log/slog" "net/http" @@ -10,7 +12,6 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" ) - // Direction indicates whether a request is inbound (caller → this agent) or // outbound (this agent → target service). type Direction int @@ -77,7 +78,7 @@ type Context struct { StartedAt time.Time Agent *AgentIdentity - Claims *validation.Claims // nil before jwt-validation runs + Claims *validation.Claims // nil before jwt-validation runs Route *routing.ResolvedRoute Session *SessionView // nil unless session tracking is enabled @@ -97,6 +98,17 @@ type Context struct { // Unexported so plugins can only set them indirectly (via the framework). currentPlugin string currentPhase InvocationPhase + + // bodyMutated / responseBodyMutated flag that a plugin called + // SetBody / SetResponseBody on this context. Listeners read the flag + // via BodyMutated() / ResponseBodyMutated() after Run / RunResponse + // to decide whether to emit a body mutation on the wire. + // + // Flags (not byte-comparison) because a mutator that rewrites to + // byte-identical content still wants the Invocation recorded — + // "tried to redact, nothing matched" is valid telemetry. + bodyMutated bool + responseBodyMutated bool } // SetCurrentPlugin is called by Pipeline.Run / RunResponse immediately @@ -191,6 +203,90 @@ func (c *Context) DenyAndRecord(reason, code, message string) Action { return Deny(code, message) } +// SetBody replaces the request body with newBody. Only meaningful when +// the plugin declares WritesBody: true in its Capabilities — the +// listener consults pctx.BodyMutated() after Run to decide whether to +// emit the new bytes on the wire. Plugins without WritesBody that call +// SetBody mutate the in-memory Context (readers downstream see the +// change), but the wire is unchanged. +// +// SetBody auto-emits a modify-action Invocation with Reason +// "body_rewritten" and publishes a plugin-public event under +// "body-mutation/event" carrying the before/after length and sha256 — +// never the body content. The session store has no auth, so raw bodies +// would be a privacy / credential leak. +// +// Callers should NOT assign pctx.Body directly — the listener wouldn't +// know to propagate the change, and the Invocation wouldn't be emitted. +func (c *Context) SetBody(newBody []byte) { + old := c.Body + c.Body = newBody + c.bodyMutated = true + c.emitBodyMutation("request", old, newBody) +} + +// SetResponseBody is the response-side analogue of SetBody. Used by +// plugins that redact or rewrite the upstream response (prompt-safety +// guardrails on LLM output, content filters, DLP). Same contract — +// Invocation + body-mutation/event emitted; never logs the body. +func (c *Context) SetResponseBody(newBody []byte) { + old := c.ResponseBody + c.ResponseBody = newBody + c.responseBodyMutated = true + c.emitBodyMutation("response", old, newBody) +} + +// BodyMutated reports whether a plugin called SetBody during this +// request. Listeners check this after Run to decide whether to emit a +// body mutation on the wire. Stream-scoped — a new Context starts with +// false regardless of what a previous request did. +func (c *Context) BodyMutated() bool { return c.bodyMutated } + +// ResponseBodyMutated is the response-side analogue of BodyMutated. +func (c *Context) ResponseBodyMutated() bool { return c.responseBodyMutated } + +// emitBodyMutation records the Invocation and publishes the +// plugin-public event carrying length delta + sha256 before/after. +// Never logs raw body bytes — the session store is unauthenticated. +func (c *Context) emitBodyMutation(phase string, oldBody, newBody []byte) { + c.Record(Invocation{Action: ActionModify, Reason: "body_rewritten"}) + + if c.Extensions.Custom == nil { + c.Extensions.Custom = map[string]any{} + } + // Prefix with a synthetic "body-mutation" plugin name — per the + // convention in extensions.go, keys MUST be the plugin's Name(). We + // use a fixed plugin-like prefix here because the framework (not a + // specific plugin) owns this event: a switch of plugin names in a + // future refactor shouldn't break operators' dashboards. + c.Extensions.Custom["body-mutation"+PluginEventSuffix] = bodyMutationEvent{ + Phase: phase, + Plugin: c.currentPlugin, + LengthBefore: len(oldBody), + LengthAfter: len(newBody), + SHA256Before: hashHex(oldBody), + SHA256After: hashHex(newBody), + } +} + +// bodyMutationEvent is the public payload shape under the +// body-mutation/event key. Purely observational — no raw body bytes. +// Consumers (abctl, audit systems) can render a per-mutation timeline +// with these fields alone. +type bodyMutationEvent struct { + Phase string `json:"phase"` // "request" | "response" + Plugin string `json:"plugin"` // plugin that called SetBody + LengthBefore int `json:"length_before"` + LengthAfter int `json:"length_after"` + SHA256Before string `json:"sha256_before"` + SHA256After string `json:"sha256_after"` +} + +func hashHex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + // appendInvocation routes an Invocation to the right direction bucket // based on pctx.Direction. Private — plugins call Record or the // Allow/Skip/Observe/Modify helpers above. Not exported so external From 539590677818effec6f5403340047218c2223a4c Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 8 May 2026 22:30:08 -0400 Subject: [PATCH 3/7] refactor(plugins): Flip parsers from BodyAccess to ReadsBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a2a-parser, mcp-parser, inference-parser all declare ReadsBody: true (the modern alias) instead of the deprecated BodyAccess. Behavior unchanged — Normalize() folds the old alias into the new field, so runtime semantics are identical. This just moves the in-tree plugins to the post-split surface before the alias is removed. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/plugins/a2aparser.go | 4 ++-- authbridge/authlib/plugins/a2aparser_test.go | 4 ++-- authbridge/authlib/plugins/inferenceparser.go | 8 ++++---- authbridge/authlib/plugins/inferenceparser_test.go | 4 ++-- authbridge/authlib/plugins/mcpparser.go | 4 ++-- authbridge/authlib/plugins/mcpparser_test.go | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/authbridge/authlib/plugins/a2aparser.go b/authbridge/authlib/plugins/a2aparser.go index 9dd4db010..a7240b96b 100644 --- a/authbridge/authlib/plugins/a2aparser.go +++ b/authbridge/authlib/plugins/a2aparser.go @@ -24,8 +24,8 @@ func (p *A2AParser) Name() string { return "a2a-parser" } func (p *A2AParser) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{ - Writes: []string{"a2a"}, - BodyAccess: true, + Writes: []string{"a2a"}, + ReadsBody: true, } } diff --git a/authbridge/authlib/plugins/a2aparser_test.go b/authbridge/authlib/plugins/a2aparser_test.go index 2015f4195..245c69123 100644 --- a/authbridge/authlib/plugins/a2aparser_test.go +++ b/authbridge/authlib/plugins/a2aparser_test.go @@ -15,8 +15,8 @@ func TestA2AParser_Capabilities(t *testing.T) { } caps := p.Capabilities() - if !caps.BodyAccess { - t.Error("BodyAccess should be true") + if !caps.ReadsBody { + t.Error("ReadsBody should be true") } if len(caps.Writes) != 1 || caps.Writes[0] != "a2a" { t.Errorf("Writes = %v, want [a2a]", caps.Writes) diff --git a/authbridge/authlib/plugins/inferenceparser.go b/authbridge/authlib/plugins/inferenceparser.go index 270089257..9271cadfb 100644 --- a/authbridge/authlib/plugins/inferenceparser.go +++ b/authbridge/authlib/plugins/inferenceparser.go @@ -24,8 +24,8 @@ func (p *InferenceParser) Name() string { return "inference-parser" } func (p *InferenceParser) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{ - Writes: []string{"inference"}, - BodyAccess: true, + Writes: []string{"inference"}, + ReadsBody: true, } } @@ -192,8 +192,8 @@ type inferenceChoice struct { // Unmarshaler) because responses only carry plain-string content + an // optional tool_calls array. type inferenceRespMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` ToolCalls []inferenceRespToolCall `json:"tool_calls"` } diff --git a/authbridge/authlib/plugins/inferenceparser_test.go b/authbridge/authlib/plugins/inferenceparser_test.go index 5e9f86b0a..e99afb9d7 100644 --- a/authbridge/authlib/plugins/inferenceparser_test.go +++ b/authbridge/authlib/plugins/inferenceparser_test.go @@ -15,8 +15,8 @@ func TestInferenceParser_Capabilities(t *testing.T) { } caps := p.Capabilities() - if !caps.BodyAccess { - t.Error("BodyAccess should be true") + if !caps.ReadsBody { + t.Error("ReadsBody should be true") } if len(caps.Writes) != 1 || caps.Writes[0] != "inference" { t.Errorf("Writes = %v, want [inference]", caps.Writes) diff --git a/authbridge/authlib/plugins/mcpparser.go b/authbridge/authlib/plugins/mcpparser.go index e41ccc644..33c6d6511 100644 --- a/authbridge/authlib/plugins/mcpparser.go +++ b/authbridge/authlib/plugins/mcpparser.go @@ -24,8 +24,8 @@ func (p *MCPParser) Name() string { return "mcp-parser" } func (p *MCPParser) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{ - Writes: []string{"mcp"}, - BodyAccess: true, + Writes: []string{"mcp"}, + ReadsBody: true, } } diff --git a/authbridge/authlib/plugins/mcpparser_test.go b/authbridge/authlib/plugins/mcpparser_test.go index 3d221cd7d..27611a9cc 100644 --- a/authbridge/authlib/plugins/mcpparser_test.go +++ b/authbridge/authlib/plugins/mcpparser_test.go @@ -15,8 +15,8 @@ func TestMCPParser_Capabilities(t *testing.T) { } caps := p.Capabilities() - if !caps.BodyAccess { - t.Error("BodyAccess should be true") + if !caps.ReadsBody { + t.Error("ReadsBody should be true") } if len(caps.Writes) != 1 || caps.Writes[0] != "mcp" { t.Errorf("Writes = %v, want [mcp]", caps.Writes) From 05c703ccb6f651a6d1dc7e43869342762b5ee60a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 8 May 2026 22:30:16 -0400 Subject: [PATCH 4/7] refactor(listeners): Use ResponseBodyMutated flag for body propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extproc, forwardproxy, reverseproxy all previously decided "did a plugin mutate the response body?" via a per-request heuristic: string-compare against the buffered original in extproc, nil-check in the two proxies. Both paths miss edge cases: - string-compare is O(n) on every response, even when no plugin in the pipeline could possibly mutate. - nil-check misfires if a plugin ever assigns pctx.ResponseBody = nil intentionally, and reacts to unrelated writes. Replaced with pctx.ResponseBodyMutated() — a single bool set inside Context.SetResponseBody. Listeners read it once after RunResponse. If the proxy listeners take the mutation path, they also clear Content-Encoding: the framework can't know whether the plugin decompressed before rewriting, so shipping plain bytes without the encoding header is safer than shipping a malformed archive. Behavior today is unchanged — SetResponseBody is wired for the response path; the only existing in-tree mutator path still uses direct pctx.ResponseBody = ... assignment (which doesn't flip the flag). A subsequent Phase B commit adds the request-body mutation path and a migration guide for the direct-assignment usage. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/authbridge/listener/extproc/server.go | 9 ++++++++- .../cmd/authbridge/listener/forwardproxy/server.go | 9 +++++++-- .../cmd/authbridge/listener/reverseproxy/server.go | 8 ++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/authbridge/cmd/authbridge/listener/extproc/server.go b/authbridge/cmd/authbridge/listener/extproc/server.go index 777136c6e..1e412e659 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server.go +++ b/authbridge/cmd/authbridge/listener/extproc/server.go @@ -679,7 +679,14 @@ func (s *Server) handleResponseBody(ctx context.Context, body []byte, pctx *pipe s.recordOutboundResponseSession(pctx) } - if string(pctx.ResponseBody) != string(body) { + // A plugin that declared WritesBody: true and called pctx.SetResponseBody + // flips the ResponseBodyMutated flag. Emit the replacement bytes via + // BodyMutation so Envoy rewrites the downstream response; otherwise + // pass through with no mutation. The flag avoids the O(n) string + // compare the old path did on every response, and lets a no-op rewrite + // (bytes unchanged but intent was to redact-nothing) still route + // through the mutation path if a future test needs to observe it. + if pctx.ResponseBodyMutated() { return &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_ResponseBody{ ResponseBody: &extprocv3.BodyResponse{ diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/cmd/authbridge/listener/forwardproxy/server.go index c9a35ee89..fd56d9c45 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -157,11 +157,16 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { return } - // If a plugin mutated ResponseBody, use the mutated version. - if s.OutboundPipeline.NeedsBody() && pctx.ResponseBody != nil { + // A plugin that called pctx.SetResponseBody flipped the mutation flag. + // Use the replaced bytes and rewrite Content-Length so the downstream + // client gets a consistent response. Content-Encoding is cleared + // because the framework can't know if the plugin also decompressed; + // safer to ship plain bytes than a broken archive. + if pctx.ResponseBodyMutated() { resp.Body = io.NopCloser(bytes.NewReader(pctx.ResponseBody)) resp.ContentLength = int64(len(pctx.ResponseBody)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(pctx.ResponseBody))) + resp.Header.Del("Content-Encoding") } if s.Sessions != nil { diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server.go b/authbridge/cmd/authbridge/listener/reverseproxy/server.go index 069615e52..6a4a27c90 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server.go @@ -146,11 +146,15 @@ func (s *Server) modifyResponse(resp *http.Response) error { return &responseRejectedError{action: action} } - // If a plugin mutated ResponseBody, use the mutated version. - if s.InboundPipeline.NeedsBody() && pctx.ResponseBody != nil { + // A plugin that called pctx.SetResponseBody flipped the mutation flag. + // Use the replaced bytes and rewrite Content-Length so the downstream + // client gets a consistent response. Content-Encoding is cleared — + // see the same comment in forwardproxy for the rationale. + if pctx.ResponseBodyMutated() { resp.Body = io.NopCloser(bytes.NewReader(pctx.ResponseBody)) resp.ContentLength = int64(len(pctx.ResponseBody)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(pctx.ResponseBody))) + resp.Header.Del("Content-Encoding") } return nil } From 9c94a35c26223b5ca670564e355e451f57f44e72 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 8 May 2026 22:37:41 -0400 Subject: [PATCH 5/7] feat(listeners): Propagate request-body mutations to upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin with WritesBody: true can now call pctx.SetBody() and have the rewritten bytes reach the upstream, across all three listener modes. Completes the request-side half of body mutation (the response side was already partially plumbed; earlier commit in this branch formalized it via the ResponseBodyMutated flag). Per-listener changes: extproc (handleInboundBody, handleOutboundBody) New helper `withBodyMutation` decorates a RequestBody ProcessingResponse with an ext_proc BodyMutation carrying pctx.Body and appends content-encoding to the HeaderMutation RemoveHeaders. No-op when pctx.BodyMutated() is false — zero cost for the common read-only pipeline. forwardproxy After OutboundPipeline.Run, if pctx.BodyMutated(), rebuild r.Body from pctx.Body, set r.ContentLength + Content-Length header, and clear Content-Encoding. reverseproxy Same pattern as forwardproxy, on the inbound request before it's handed to httputil.ReverseProxy. Content-Encoding is cleared on every mutation path because the framework can't know whether the plugin decompressed before rewriting; shipping plain bytes without the old encoding header is safer than shipping a malformed archive. Auto-decompress/recompress is a future feature, out of scope here. Tests (one per listener): - A bodyMutatorPlugin declares WritesBody and rewrites pctx.Body. - The upstream backend (or the ProcessingResponse in extproc's case) must receive the new bytes with a correct Content-Length and no Content-Encoding. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../cmd/authbridge/listener/extproc/server.go | 38 ++++++++- .../listener/extproc/server_test.go | 84 +++++++++++++++++++ .../listener/forwardproxy/server.go | 10 +++ .../listener/forwardproxy/server_test.go | 74 ++++++++++++++++ .../listener/reverseproxy/server.go | 10 +++ .../listener/reverseproxy/server_test.go | 74 ++++++++++++++++ 6 files changed, 287 insertions(+), 3 deletions(-) diff --git a/authbridge/cmd/authbridge/listener/extproc/server.go b/authbridge/cmd/authbridge/listener/extproc/server.go index 1e412e659..dd9d444c7 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server.go +++ b/authbridge/cmd/authbridge/listener/extproc/server.go @@ -160,7 +160,7 @@ func (s *Server) handleInboundBody(stream extprocv3.ExternalProcessor_ProcessSer } s.recordInboundSession(pctx) - return allowBodyResponse(), pctx + return withBodyMutation(allowBodyResponse(), pctx), pctx } // inboundSessionID returns the bucket ID for an inbound event. Trusts the @@ -589,9 +589,9 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe newAuth := pctx.Headers.Get("Authorization") if newAuth != originalAuth { - return replaceTokenBodyResponse(extractBearer(newAuth)), pctx + return withBodyMutation(replaceTokenBodyResponse(extractBearer(newAuth)), pctx), pctx } - return passBodyResponse(), pctx + return withBodyMutation(passBodyResponse(), pctx), pctx } func (s *Server) handleResponseHeaders(ctx context.Context, headers *corev3.HeaderMap, pctx *pipeline.Context, direction string) *extprocv3.ProcessingResponse { @@ -767,6 +767,38 @@ func passBodyResponse() *extprocv3.ProcessingResponse { } } +// withBodyMutation optionally decorates a RequestBody ProcessingResponse +// with an ext_proc BodyMutation when the pipeline rewrote pctx.Body. +// Envoy replaces the buffered body with the new bytes and recomputes +// Content-Length for the upstream. We also clear content-encoding +// because the plugin may have decompressed + rewritten in plaintext; +// shipping plain bytes without the old encoding header is safer than +// shipping a malformed archive. +// +// No-op when pctx.BodyMutated() is false — the common case of a +// read-only pipeline pays no cost beyond the bool read. +func withBodyMutation(resp *extprocv3.ProcessingResponse, pctx *pipeline.Context) *extprocv3.ProcessingResponse { + if !pctx.BodyMutated() { + return resp + } + br, ok := resp.Response.(*extprocv3.ProcessingResponse_RequestBody) + if !ok || br.RequestBody == nil { + return resp // response is an ImmediateResponse or shaped differently; leave alone. + } + if br.RequestBody.Response == nil { + br.RequestBody.Response = &extprocv3.CommonResponse{} + } + cr := br.RequestBody.Response + cr.BodyMutation = &extprocv3.BodyMutation{ + Mutation: &extprocv3.BodyMutation_Body{Body: pctx.Body}, + } + if cr.HeaderMutation == nil { + cr.HeaderMutation = &extprocv3.HeaderMutation{} + } + cr.HeaderMutation.RemoveHeaders = append(cr.HeaderMutation.RemoveHeaders, "content-encoding") + return resp +} + func allowBodyResponse() *extprocv3.ProcessingResponse { return &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_RequestBody{ diff --git a/authbridge/cmd/authbridge/listener/extproc/server_test.go b/authbridge/cmd/authbridge/listener/extproc/server_test.go index bed976304..e3183a880 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server_test.go +++ b/authbridge/cmd/authbridge/listener/extproc/server_test.go @@ -385,6 +385,90 @@ func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) return pipeline.Action{Type: pipeline.Continue} } +// bodyMutatorPlugin declares WritesBody and rewrites pctx.Body via +// SetBody. Used to assert extproc emits a BodyMutation on the wire +// when a plugin rewrites the request body. +type bodyMutatorPlugin struct { + newBody []byte +} + +func (p *bodyMutatorPlugin) Name() string { return "body-mutator" } +func (p *bodyMutatorPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{WritesBody: true} +} +func (p *bodyMutatorPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.SetBody(p.newBody) + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bodyMutatorPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// TestExtProc_RequestBodyMutation_Inbound: a WritesBody plugin must +// produce a RequestBody ProcessingResponse carrying BodyMutation with +// the new bytes, and the header mutation must request content-encoding +// be removed. +func TestExtProc_RequestBodyMutation_Inbound(t *testing.T) { + mutator := &bodyMutatorPlugin{newBody: []byte(`{"sanitized":"v"}`)} + inbound, err := pipeline.New([]pipeline.Plugin{mutator}) + if err != nil { + t.Fatal(err) + } + outbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{plugintesting.NewTokenExchange(auth.New(auth.Config{}))}) + if err != nil { + t.Fatal(err) + } + srv := &Server{InboundPipeline: inbound, OutboundPipeline: outbound} + + body := []byte(`{"original":"payload"}`) + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + ":method", "POST", + ":path", "/mcp", + "content-length", fmt.Sprintf("%d", len(body)), + )), + {Request: &extprocv3.ProcessingRequest_RequestBody{ + RequestBody: &extprocv3.HttpBody{Body: body}, + }}, + }, + } + _ = srv.Process(stream) + + if len(stream.responses) != 2 { + t.Fatalf("expected 2 responses, got %d", len(stream.responses)) + } + + // Second response is the body-phase result. Unwrap it and assert + // BodyMutation carries the mutator's new bytes. + rb := stream.responses[1].GetRequestBody() + if rb == nil || rb.Response == nil || rb.Response.BodyMutation == nil { + t.Fatalf("expected RequestBody.Response.BodyMutation, got %+v", stream.responses[1]) + } + gotBody := rb.Response.BodyMutation.GetBody() + if string(gotBody) != string(mutator.newBody) { + t.Errorf("BodyMutation.Body = %q, want %q", gotBody, mutator.newBody) + } + + // Header mutation should include content-encoding in RemoveHeaders. + hm := rb.Response.HeaderMutation + if hm == nil { + t.Fatal("expected HeaderMutation to clear content-encoding") + } + found := false + for _, h := range hm.RemoveHeaders { + if h == "content-encoding" { + found = true + break + } + } + if !found { + t.Errorf("RemoveHeaders = %v, want to include content-encoding", hm.RemoveHeaders) + } +} + func TestExtProc_BodyBuffering_Inbound(t *testing.T) { recorder := &bodyRecorderPlugin{} p, err := pipeline.New([]pipeline.Plugin{recorder}) diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/cmd/authbridge/listener/forwardproxy/server.go index fd56d9c45..d7cac81d7 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -110,6 +110,16 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { r.Header.Set("Authorization", "Bearer "+extractBearer(newAuth)) } + // If a WritesBody plugin rewrote pctx.Body, ship the new bytes + // upstream and clear Content-Encoding (see forwardproxy response + // path for the rationale). + if pctx.BodyMutated() { + r.Body = io.NopCloser(bytes.NewReader(pctx.Body)) + r.ContentLength = int64(len(pctx.Body)) + r.Header.Set("Content-Length", fmt.Sprintf("%d", len(pctx.Body))) + r.Header.Del("Content-Encoding") + } + // Remove hop-by-hop headers r.Header.Del("Connection") r.Header.Del("Keep-Alive") diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go index bbdf29893..bb41cd3a4 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go @@ -3,6 +3,7 @@ package forwardproxy import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "net/url" @@ -158,6 +159,79 @@ func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) return pipeline.Action{Type: pipeline.Continue} } +// bodyMutatorPlugin declares WritesBody and rewrites pctx.Body via +// SetBody. Used below to confirm the forwardproxy propagates the +// mutation to the upstream request. +type bodyMutatorPlugin struct { + newBody []byte +} + +func (p *bodyMutatorPlugin) Name() string { return "body-mutator" } +func (p *bodyMutatorPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{WritesBody: true} +} +func (p *bodyMutatorPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.SetBody(p.newBody) + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bodyMutatorPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// TestForwardProxy_RequestBodyMutation: a WritesBody plugin rewriting +// pctx.Body must cause the upstream backend to receive the new bytes +// with a correct Content-Length and no Content-Encoding. +func TestForwardProxy_RequestBodyMutation(t *testing.T) { + newBody := `{"sanitized":"payload"}` + var ( + gotBody []byte + gotLength string + gotEnc string + ) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotLength = r.Header.Get("Content-Length") + gotEnc = r.Header.Get("Content-Encoding") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + mutator := &bodyMutatorPlugin{newBody: []byte(newBody)} + p, err := pipeline.New([]pipeline.Plugin{mutator}) + if err != nil { + t.Fatal(err) + } + srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + orig := `{"original":"prompt"}` + req, _ := http.NewRequest("POST", backend.URL+"/agent", strings.NewReader(orig)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "gzip") + + proxyClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(mustParseURL(proxy.URL)), + }, + } + resp, err := proxyClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if string(gotBody) != newBody { + t.Errorf("backend got body = %q, want %q", gotBody, newBody) + } + if gotLength != "23" { + t.Errorf("Content-Length = %q, want 23", gotLength) + } + if gotEnc != "" { + t.Errorf("Content-Encoding = %q, want empty", gotEnc) + } +} + func TestForwardProxy_BodyBuffering(t *testing.T) { recorder := &bodyRecorderPlugin{} p, err := pipeline.New([]pipeline.Plugin{recorder}) diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server.go b/authbridge/cmd/authbridge/listener/reverseproxy/server.go index 6a4a27c90..ac40ed98a 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server.go @@ -99,6 +99,16 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { return } + // If a WritesBody plugin rewrote pctx.Body, send the new bytes to + // the backend and clear Content-Encoding (same rationale as the + // response path — plugin may have decompressed). + if pctx.BodyMutated() { + r.Body = io.NopCloser(bytes.NewReader(pctx.Body)) + r.ContentLength = int64(len(pctx.Body)) + r.Header.Set("Content-Length", fmt.Sprintf("%d", len(pctx.Body))) + r.Header.Del("Content-Encoding") + } + if s.Sessions != nil && pctx.Extensions.A2A != nil { sid := pctx.Extensions.A2A.SessionID if sid == "" { diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go index 22f85c4bf..c801571c2 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go @@ -3,6 +3,7 @@ package reverseproxy import ( "context" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -125,6 +126,79 @@ func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) return pipeline.Action{Type: pipeline.Continue} } +// bodyMutatorPlugin declares WritesBody and rewrites the request body +// to a fixed payload. The pipeline validator requires WritesBody run +// after any ReadsBody plugin, which this satisfies by itself (no reader +// present when used alone). +type bodyMutatorPlugin struct { + newBody []byte +} + +func (p *bodyMutatorPlugin) Name() string { return "body-mutator" } +func (p *bodyMutatorPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{WritesBody: true} +} +func (p *bodyMutatorPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.SetBody(p.newBody) + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bodyMutatorPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// TestReverseProxy_RequestBodyMutation: a WritesBody plugin that +// rewrites pctx.Body via SetBody must cause the upstream backend to +// receive the new bytes with a correct Content-Length header. Confirms +// that the reverseproxy request-path propagation is wired to the +// BodyMutated flag. +func TestReverseProxy_RequestBodyMutation(t *testing.T) { + newBody := `{"sanitized":"payload"}` + var ( + gotBody []byte + gotLength string + gotEnc string + ) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotLength = r.Header.Get("Content-Length") + gotEnc = r.Header.Get("Content-Encoding") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + mutator := &bodyMutatorPlugin{newBody: []byte(newBody)} + p, err := pipeline.New([]pipeline.Plugin{mutator}) + if err != nil { + t.Fatal(err) + } + srv, err := NewServer(p, nil, backend.URL) + if err != nil { + t.Fatal(err) + } + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + orig := `{"original":"prompt"}` + req, _ := http.NewRequest("POST", proxy.URL+"/agent", strings.NewReader(orig)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "gzip") // plugin may have decompressed; listener clears + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if string(gotBody) != newBody { + t.Errorf("backend got body = %q, want %q (listener did not propagate mutation)", gotBody, newBody) + } + if gotLength != "23" { // len(`{"sanitized":"payload"}`) + t.Errorf("Content-Length = %q, want 23 (mutation rewrite)", gotLength) + } + if gotEnc != "" { + t.Errorf("Content-Encoding = %q, want empty (listener should clear on mutation)", gotEnc) + } +} + func TestReverseProxy_BodyBuffering(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) From 0e83b1afb1d4438e80cfee5876a5d0708ede60b7 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 8 May 2026 22:52:22 -0400 Subject: [PATCH 6/7] docs: Body mutation lifecycle and plugin-author surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doc files updated to reflect the capability split, SetBody / SetResponseBody API, and per-listener wire propagation. framework-architecture.md §2 PluginCapabilities: struct snippet now shows ReadsBody / WritesBody / deprecated BodyAccess; prose explains how each affects listener negotiation. §3 Context ownership rules: new bullet on body read vs mutation, and the "don't assign pctx.Body directly" guidance. §6 Pipeline: new "Body mutation" subsection covering the capability model, build-time validation rules, mutation helpers, per-listener wire propagation, content-encoding policy, size limits, and explicit streaming scope note. §11 Versioning: changelog entry for the body-mutation feature. §12 Cross-references: package-source entries updated with new methods / fields. plugin-reference.md New "Body mutation" section between the Custom-map graduation criteria and "Registering a plugin." Field-level reference for PluginCapabilities (including the deprecated BodyAccess alias), the New-time validation rules, a mutation helper table, and the "NEVER log raw body" rule. Cross-links to framework-architecture §6.5 for the full lifecycle. plugin-tutorial.md Step 5 rewritten: BodyAccess -> ReadsBody, plus a new "Mutating the body" subsection with a Redactor example using SetBody and the ordering rules spelled out. No code changes. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/docs/framework-architecture.md | 55 ++++++++++++++++++-- authbridge/docs/plugin-reference.md | 63 +++++++++++++++++++++++ authbridge/docs/plugin-tutorial.md | 48 +++++++++++++++-- 3 files changed, 156 insertions(+), 10 deletions(-) diff --git a/authbridge/docs/framework-architecture.md b/authbridge/docs/framework-architecture.md index 967adacc8..af5add840 100644 --- a/authbridge/docs/framework-architecture.md +++ b/authbridge/docs/framework-architecture.md @@ -73,7 +73,9 @@ A stable identifier. Used for logs, metrics, `GetState`/`SetState` keys (by conv type PluginCapabilities struct { Reads []string // extension slot names this plugin reads Writes []string // extension slot names this plugin writes - BodyAccess bool // whether this plugin needs request/response body buffered + ReadsBody bool // plugin reads pctx.Body / pctx.ResponseBody + WritesBody bool // plugin mutates body via pctx.SetBody / pctx.SetResponseBody + BodyAccess bool // deprecated: alias for ReadsBody (folded by Normalize) } ``` @@ -83,7 +85,9 @@ Declared once per plugin instance. `pipeline.New` validates that every `Read` is plugin "guardrail" reads slot "mcp" but no earlier plugin writes it ``` -`BodyAccess: true` on *any* plugin in a chain causes `Pipeline.NeedsBody()` to return true, which the **listener** uses to negotiate Envoy's `ProcessingMode` (BUFFERED vs HEADERS-only). Without this, the gRPC ext_proc server never asks for the body and parsers see `pctx.Body == nil`. +`ReadsBody: true` (or the legacy `BodyAccess` alias) on *any* plugin in a chain causes `Pipeline.NeedsBody()` to return true, which the **listener** uses to negotiate Envoy's `ProcessingMode` (BUFFERED vs HEADERS-only). Without this, the gRPC ext_proc server never asks for the body and parsers see `pctx.Body == nil`. + +`WritesBody: true` declares that the plugin may rewrite the body via `pctx.SetBody` / `pctx.SetResponseBody`; the listener propagates the mutation to the wire. See §6, "Body mutation" for the full body-mutation contract (capability rules, ordering constraints, Content-Encoding policy). ### `OnRequest(ctx, pctx) Action` Called when a request is entering the pipeline. Plugins typically read request headers / body, mutate one or more extension slots, and return `Continue` or `Reject`. @@ -126,6 +130,8 @@ type Context struct { **Ownership rules:** - Plugins **read** any field they declared in `Capabilities.Reads`. - Plugins **write** fields they declared in `Capabilities.Writes`. By convention each extension slot has exactly one writer (the parser plugin). +- Plugins read `pctx.Body` / `pctx.ResponseBody` only if they declared `ReadsBody: true` (or the deprecated `BodyAccess: true`). +- Plugins mutate body content via `pctx.SetBody(newBytes)` / `pctx.SetResponseBody(newBytes)`, and only if they declared `WritesBody: true`. Direct assignment (`pctx.Body = ...`) compiles but bypasses listener propagation and misses the Invocation + body-mutation event emission — see §6, "Body mutation." - `Claims` is populated by `jwt-validation` and is read-only afterward. - `Agent`, `Route`, `Session` are populated by the listener before `Run`. Plugins treat them as read-only. - `ResponseBody` appears between `Run` and `RunResponse` — plugins must not read it in `OnRequest`. @@ -478,6 +484,44 @@ This tells the validator those slot names are legal, so a downstream plugin can ### Concurrency model Always sequential. No priority / mode / fire-and-forget semantics yet. This is the 80% case for auth-and-parse pipelines; richer modes would require an executor layer above the current loop. +### Body mutation + +A plugin that declares `WritesBody: true` may rewrite the request or response body. The framework owns the propagation to the wire; plugins only call `pctx.SetBody(newBytes)` / `pctx.SetResponseBody(newBytes)`. + +**Capability model.** Three booleans on `PluginCapabilities`: + +| Field | Meaning | Listener effect | +|---|---|---| +| `ReadsBody` | plugin reads `pctx.Body` / `pctx.ResponseBody` | buffers the body; plugin sees the bytes | +| `WritesBody` | plugin may call `pctx.SetBody` / `pctx.SetResponseBody` | implies `ReadsBody`; propagates mutations | +| `BodyAccess` (deprecated) | legacy alias for `ReadsBody` | folded by `Normalize()`, removed in a future release | + +`pipeline.New` enforces two rules at build time: + +1. **At most one `WritesBody` plugin per pipeline.** Multiple mutators would have ambiguous ordering semantics; the error names both plugins so an operator debugging pod logs knows which two to reconcile. +2. **`WritesBody` cannot precede a `ReadsBody`-only plugin.** A reader expects to see the original bytes; putting a mutator before it would silently feed the reader the post-rewrite content. + +**Mutation helpers.** `SetBody` / `SetResponseBody` replace the byte slice and flip an internal `bodyMutated` / `responseBodyMutated` flag that listeners read via `pctx.BodyMutated()` / `pctx.ResponseBodyMutated()`. They also auto-emit: + +- A `modify`-action Invocation with `Reason: "body_rewritten"`, framework-attributed to the mutating plugin. +- A plugin-public event under `pctx.Extensions.Custom["body-mutation" + PluginEventSuffix]` with the phase (`request` / `response`), plugin name, byte length before/after, and sha256 before/after. Never the raw body content — the session store is unauthenticated. + +The flags (not byte-compare) are the source of truth. A rewrite that produces byte-identical output still records the Invocation because "redactor ran, nothing matched" is valid telemetry. + +**Wire propagation (per listener).** + +| Listener | Request body | Response body | +|---|---|---| +| `extproc` | `withBodyMutation` helper wraps the `RequestBody ProcessingResponse` with an ext_proc `BodyMutation`. Envoy replaces the buffered body and recomputes `Content-Length`. | Same pattern in the response-body handler. | +| `forwardproxy` | On mutation, rebuild `r.Body` from `pctx.Body`, set `r.ContentLength` + `Content-Length` header. | Replace `resp.Body` + `resp.ContentLength` + `Content-Length`. | +| `reverseproxy` | Same as forwardproxy on the inbound request before handing to `httputil.ReverseProxy`. | Same as forwardproxy on the response from the upstream. | + +Every mutation path also **clears `Content-Encoding`**: the framework can't know whether the plugin decompressed a gzipped body before rewriting, so shipping plain bytes without the old encoding header beats shipping a malformed archive. Auto-decompress/recompress is a possible future feature, explicitly out of scope today. + +**Body-size limits.** Bodies over `maxBodySize` (1 MB) are rejected by the listener at buffer time — before the mutator sees anything. Plugins don't need to guard against oversized input. + +**Streaming.** Only buffered mutation is supported. Responses that stream (SSE, chunked bodies beyond the buffer cap) are not mutable today; a streaming-transform API is a separate project. + --- ## 7. `Session` + `SessionEvent` — the observability side-channel @@ -629,6 +673,7 @@ The plugin interface is **not** semver-stable yet (AuthBridge is pre-1.0). Chang - **`pctx.Record` helpers**: `Allow` / `Skip` / `Observe` / `Modify` / `Record` / `DenyAndRecord` on `Context`. Framework-managed attribution (`currentPlugin`, `currentPhase`, `Path`) fills Invocation fields automatically. - **Open plugin registry**: plugins self-register from `init()` via `plugins.RegisterPlugin`. Third-party plugins in external modules drop in via a side-effect import. Closed `registry` map literal removed. - **Config hot-reload**: new `pipeline.Holder` (atomic wrapper) + `authlib/reloader` package (fsnotify-driven). Listeners receive `*Holder` instead of `*Pipeline`; the reloader atomically swaps the holder's contents when the config file changes. `mode` and `listener.*` edits are refused (pod restart required); any other change is picked up within the kubelet sync window (~60s). See §9. +- **Body mutation**: `PluginCapabilities.BodyAccess` split into `ReadsBody` / `WritesBody`. New `pctx.SetBody` / `pctx.SetResponseBody` helpers flip a mutation flag; all three listeners (extproc / forwardproxy / reverseproxy) propagate the rewrite to the upstream with correct `Content-Length` and cleared `Content-Encoding`. `BodyAccess` kept as deprecated alias. See §6, "Body mutation." Breaking changes will be announced in `authbridge/CHANGELOG.md` (TBD) before a 1.0 tag. @@ -643,11 +688,11 @@ Breaking changes will be announced in `authbridge/CHANGELOG.md` (TBD) before a 1 **Package sources:** -- `pipeline.go` — `Pipeline` type, `New`, `Run`, `RunResponse`, `Start`, `Stop`, `Plugins`, `NeedsBody`. +- `pipeline.go` — `Pipeline` type, `New`, `Run`, `RunResponse`, `Start`, `Stop`, `Plugins`, `NeedsBody`, `WritesBody`. - `holder.go` — `Holder`, the atomic slot listeners hold in place of a raw `*Pipeline`. -- `plugin.go` — `Plugin` interface, `PluginCapabilities`, `Configurable`, `Initializer`, `Shutdowner`, `Readier`. +- `plugin.go` — `Plugin` interface, `PluginCapabilities` (with `ReadsBody` / `WritesBody` / deprecated `BodyAccess` + `Normalize()`), `Configurable`, `Initializer`, `Shutdowner`, `Readier`. - `action.go` — `Action`, `ActionType`, `Violation`, helper constructors (`Deny`, `DenyStatus`, `DenyWithDetails`, `Challenge`, `RateLimited`), `StatusFromCode`. -- `context.go` — `Context`, `Direction`, `AgentIdentity`, and the `pctx.Record` / `Allow` / `Skip` / `Observe` / `Modify` / `DenyAndRecord` helpers. +- `context.go` — `Context`, `Direction`, `AgentIdentity`, the `pctx.Record` / `Allow` / `Skip` / `Observe` / `Modify` / `DenyAndRecord` helpers, and `pctx.SetBody` / `SetResponseBody` / `BodyMutated` / `ResponseBodyMutated` for body mutation. - `extensions.go` — `Extensions` struct, `Invocation`, `Invocations`, `InvocationAction`, named protocol extensions, `GetState` / `SetState`. - `session.go` — `SessionEvent`, `SessionView`, `SessionPhase`, marshalers. - `authlib/reloader/` — `Reloader`, `Status`, `PipelineBuilder`, `WithDrainWindow` / `WithDebounce` / `WithStartTimeout`, `Handler()` (serves `/reload/status`). diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md index 759f9df93..d46f1d231 100644 --- a/authbridge/docs/plugin-reference.md +++ b/authbridge/docs/plugin-reference.md @@ -423,6 +423,69 @@ Graduate to a typed slot when ≥2 of these are true: Don't graduate speculatively — the map path has no cost if you stay in it. +## Body mutation + +Plugins that need to rewrite request or response bodies declare +`WritesBody: true` and call the `pctx.SetBody` / `pctx.SetResponseBody` +helpers. The framework propagates the rewrite to the wire, emits a +`modify`-action Invocation, and publishes a `body-mutation/event` +entry in `pctx.Extensions.Custom` with length delta + sha256 +before/after (never the raw body). + +> For the full lifecycle — per-listener wire behavior, content-encoding +> policy, ordering rules, body-size limits — see +> [`framework-architecture.md` §6, "Body mutation"](./framework-architecture.md#body-mutation). +> This section is the plugin-author field reference. + +### Capability fields + +```go +type PluginCapabilities struct { + Reads []string // extension slot names this plugin reads + Writes []string // extension slot names this plugin writes + ReadsBody bool // plugin reads pctx.Body / pctx.ResponseBody + WritesBody bool // plugin may call pctx.SetBody / pctx.SetResponseBody + BodyAccess bool // DEPRECATED alias for ReadsBody; folded by Normalize() +} +``` + +- `ReadsBody`: listener buffers the body; plugin sees bytes. +- `WritesBody`: implies `ReadsBody`. Listener propagates `pctx.SetBody` + rewrites to the upstream (and `pctx.SetResponseBody` to the + downstream client). +- `BodyAccess`: deprecated. `PluginCapabilities.Normalize()` folds it + into `ReadsBody` for one release of migration grace; new plugins + should never set it. + +### Build-time validation (enforced by `pipeline.New`) + +- At most **one** `WritesBody` plugin per pipeline. Two mutators in + the same direction would produce ambiguous ordering; `New` rejects + with an error naming both plugins. +- A `WritesBody` plugin cannot precede a `ReadsBody`-only plugin. The + reader must see the original bytes. +- Waypoint mode (ext_authz listener) cannot propagate body mutations — + the ext_authz API has no body-mutation field. Do not combine + `WritesBody: true` plugins with `mode: waypoint`. + +### Mutation helpers + +| Call | Effect | +|---|---| +| `pctx.SetBody(newBytes)` | Replace request body; flip `BodyMutated()` flag | +| `pctx.SetResponseBody(newBytes)` | Replace response body; flip `ResponseBodyMutated()` flag | +| `pctx.BodyMutated()` / `ResponseBodyMutated()` | Read by the listener to decide whether to emit a wire mutation. Plugins normally don't need these. | + +Direct assignment (`pctx.Body = newBytes`) still compiles but the +listener won't propagate it, no Invocation fires, and the mutation +event won't appear in the session stream. Always use `SetBody`. + +**NEVER log the raw body content.** The framework's +`body-mutation/event` carries only length + sha256 on purpose — the +session store is unauthenticated. Plugin-private debug logs may +include body bytes at DEBUG level, but never publish them to the +session stream or Custom map. + ## Registering a plugin A plugin advertises itself to the pipeline builder through `RegisterPlugin` diff --git a/authbridge/docs/plugin-tutorial.md b/authbridge/docs/plugin-tutorial.md index 07600c14a..bfb14f90a 100644 --- a/authbridge/docs/plugin-tutorial.md +++ b/authbridge/docs/plugin-tutorial.md @@ -171,19 +171,57 @@ for the strict-decode / defaults / validate / construct pattern. ## Step 5 — Body access If your plugin needs to read the request or response body (e.g., to -parse JSON, scan for credentials, or apply a content filter), declare -it in `Capabilities`: +parse JSON, scan for credentials), declare `ReadsBody`: ```go func (p *HelloLog) Capabilities() pipeline.PluginCapabilities { - return pipeline.PluginCapabilities{BodyAccess: true} + return pipeline.PluginCapabilities{ReadsBody: true} } ``` -The listener then tells Envoy to buffer the body so `pctx.Body` (request) -and `pctx.ResponseBody` (response) are populated. Without the declaration, +The listener buffers the body so `pctx.Body` (request) and +`pctx.ResponseBody` (response) are populated. Without the declaration, both stay nil even if you try to read them. +### Mutating the body + +If your plugin needs to **rewrite** the body — prompt-redaction, output +filtering, content transformation — declare `WritesBody` and call +`pctx.SetBody` / `pctx.SetResponseBody`: + +```go +func (p *Redactor) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{WritesBody: true} // implies ReadsBody +} + +func (p *Redactor) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + cleaned := redactSSNs(pctx.Body) + pctx.SetBody(cleaned) // auto-records a modify-action Invocation + return pipeline.Action{Type: pipeline.Continue} +} +``` + +The listener propagates the rewritten bytes to the upstream (or downstream, +for `SetResponseBody`) with a correct `Content-Length` and a cleared +`Content-Encoding`. `SetBody` also emits a `modify`-action Invocation with +`Reason: "body_rewritten"` plus a `body-mutation/event` entry in +`pctx.Extensions.Custom` carrying the length delta and sha256 before/after +(never the raw body content). + +**Rules enforced by `pipeline.New`:** +- At most one `WritesBody` plugin per pipeline. Two mutators = ambiguous + ordering → build fails at startup. +- A `WritesBody` plugin must run **after** any `ReadsBody`-only plugin. + Readers see the original bytes; a mutator in front would silently + feed them post-rewrite content. + +Don't assign `pctx.Body = newBytes` directly — the listener won't +propagate the mutation and no Invocation fires. Always use `SetBody`. + +See [`framework-architecture.md` §6, "Body mutation"](./framework-architecture.md#body-mutation) +for the full lifecycle, per-listener wire details, and content-encoding +policy. + ## Step 6 — Out-of-tree plugins A plugin living in another Go module follows the same pattern, but From d4430a83d430731fab69773dbdcbe5e4f0804e80 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 10 May 2026 08:18:37 -0400 Subject: [PATCH 7/7] fix(tests): Wrap raw *Pipeline in Holder for body-mutation integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #388 (now on main) changed listener fields from *pipeline.Pipeline to *pipeline.Holder. The body-mutation integration tests in this PR were authored before that landed and passed raw *Pipeline to Server{…} / NewServer(…); post-rebase those four spots don't compile. Wrap with pipeline.NewHolder(): extproc/server_test.go:421 &Server{InboundPipeline: inbound, …} forwardproxy/server_test.go:204 &Server{OutboundPipeline: p, …} reverseproxy/server_test.go:174 NewServer(p, nil, backend.URL) Other call sites already use the outboundPipelineFromAuth / inboundPipelineFromAuth helpers, which return *Holder, so only the tests that build pipelines inline via pipeline.New needed changes. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/authbridge/listener/extproc/server_test.go | 2 +- authbridge/cmd/authbridge/listener/forwardproxy/server_test.go | 2 +- authbridge/cmd/authbridge/listener/reverseproxy/server_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/authbridge/cmd/authbridge/listener/extproc/server_test.go b/authbridge/cmd/authbridge/listener/extproc/server_test.go index e3183a880..3c72a3ab5 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server_test.go +++ b/authbridge/cmd/authbridge/listener/extproc/server_test.go @@ -418,7 +418,7 @@ func TestExtProc_RequestBodyMutation_Inbound(t *testing.T) { if err != nil { t.Fatal(err) } - srv := &Server{InboundPipeline: inbound, OutboundPipeline: outbound} + srv := &Server{InboundPipeline: pipeline.NewHolder(inbound), OutboundPipeline: pipeline.NewHolder(outbound)} body := []byte(`{"original":"payload"}`) stream := &mockStream{ diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go index bb41cd3a4..0331a1c12 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go @@ -201,7 +201,7 @@ func TestForwardProxy_RequestBodyMutation(t *testing.T) { if err != nil { t.Fatal(err) } - srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + srv := &Server{OutboundPipeline: pipeline.NewHolder(p), Client: http.DefaultClient} proxy := httptest.NewServer(srv.Handler()) defer proxy.Close() diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go index c801571c2..27095d192 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go @@ -171,7 +171,7 @@ func TestReverseProxy_RequestBodyMutation(t *testing.T) { if err != nil { t.Fatal(err) } - srv, err := NewServer(p, nil, backend.URL) + srv, err := NewServer(pipeline.NewHolder(p), nil, backend.URL) if err != nil { t.Fatal(err) }