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 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 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) diff --git a/authbridge/cmd/authbridge/listener/extproc/server.go b/authbridge/cmd/authbridge/listener/extproc/server.go index 777136c6e..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 { @@ -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{ @@ -760,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..3c72a3ab5 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: pipeline.NewHolder(inbound), OutboundPipeline: pipeline.NewHolder(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 c9a35ee89..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") @@ -157,11 +167,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/forwardproxy/server_test.go b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go index bbdf29893..0331a1c12 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: pipeline.NewHolder(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 069615e52..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 == "" { @@ -146,11 +156,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 } diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go index 22f85c4bf..27095d192 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(pipeline.NewHolder(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) 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