diff --git a/authbridge/authlib/pipeline/pipeline.go b/authbridge/authlib/pipeline/pipeline.go index 7aeba1aa9..c8c605402 100644 --- a/authbridge/authlib/pipeline/pipeline.go +++ b/authbridge/authlib/pipeline/pipeline.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "fmt" + "log/slog" ) // Pipeline holds an ordered list of plugins and runs them sequentially. @@ -59,12 +60,16 @@ func New(plugins []Plugin, opts ...Option) (*Pipeline, error) { func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action { for _, plugin := range p.plugins { if ctx.Err() != nil { + slog.Info("pipeline: request cancelled", "plugin", plugin.Name()) return Action{Type: Reject, Status: 499, Reason: "request cancelled"} } action := plugin.OnRequest(ctx, pctx) if action.Type == Reject { + slog.Info("pipeline: plugin rejected request", + "plugin", plugin.Name(), "status", action.Status, "reason", action.Reason) return action } + slog.Debug("pipeline: plugin completed", "plugin", plugin.Name()) } return Action{Type: Continue} } @@ -74,16 +79,29 @@ func (p *Pipeline) Run(ctx context.Context, pctx *Context) Action { func (p *Pipeline) RunResponse(ctx context.Context, pctx *Context) Action { for i := len(p.plugins) - 1; i >= 0; i-- { if ctx.Err() != nil { + slog.Info("pipeline: response cancelled", "plugin", p.plugins[i].Name()) return Action{Type: Reject, Status: 499, Reason: "request cancelled"} } action := p.plugins[i].OnResponse(ctx, pctx) if action.Type == Reject { + slog.Info("pipeline: plugin rejected response", + "plugin", p.plugins[i].Name(), "status", action.Status, "reason", action.Reason) return action } } return Action{Type: Continue} } +// NeedsBody returns true if any plugin in the pipeline declares BodyAccess. +func (p *Pipeline) NeedsBody() bool { + for _, plugin := range p.plugins { + if plugin.Capabilities().BodyAccess { + return true + } + } + return false +} + // validateCapabilities checks that every slot a plugin reads has been written // by an earlier plugin in the chain. func validateCapabilities(plugins []Plugin, validSlots map[string]bool) error { diff --git a/authbridge/authlib/plugins/mcpparser.go b/authbridge/authlib/plugins/mcpparser.go new file mode 100644 index 000000000..d3c566efe --- /dev/null +++ b/authbridge/authlib/plugins/mcpparser.go @@ -0,0 +1,106 @@ +package plugins + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +// MCPParser parses MCP JSON-RPC 2.0 request bodies and populates +// pctx.Extensions.MCP with the parsed method, tool name, resource URI, +// or prompt name for downstream policy plugins. +type MCPParser struct{} + +func NewMCPParser() *MCPParser { return &MCPParser{} } + +func (p *MCPParser) Name() string { return "mcp-parser" } + +func (p *MCPParser) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{ + Writes: []string{"mcp"}, + BodyAccess: true, + } +} + +func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + if len(pctx.Body) == 0 { + slog.Debug("mcp-parser: no body, skipping") + return pipeline.Action{Type: pipeline.Continue} + } + + var rpc jsonRPCRequest + if err := json.Unmarshal(pctx.Body, &rpc); err != nil { + slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) + return pipeline.Action{Type: pipeline.Continue} + } + + ext := &pipeline.MCPExtension{ + Method: rpc.Method, + RPCID: rpc.ID, + } + + switch rpc.Method { + case "tools/call": + ext.Tool = &pipeline.MCPToolMetadata{ + Name: rpc.stringParam("name"), + Args: rpc.mapParam("arguments"), + } + slog.Info("mcp-parser: parsed tools/call", "tool", ext.Tool.Name) + case "resources/read": + ext.Resource = &pipeline.MCPResourceMetadata{ + URI: rpc.stringParam("uri"), + } + slog.Info("mcp-parser: parsed resources/read", "uri", ext.Resource.URI) + case "prompts/get": + ext.Prompt = &pipeline.MCPPromptMetadata{ + Name: rpc.stringParam("name"), + Args: rpc.stringMapParam("arguments"), + } + slog.Info("mcp-parser: parsed prompts/get", "prompt", ext.Prompt.Name) + default: + slog.Debug("mcp-parser: untracked method", "method", rpc.Method) + } + + pctx.Extensions.MCP = ext + return pipeline.Action{Type: pipeline.Continue} +} + +func (p *MCPParser) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +type jsonRPCRequest struct { + Method string `json:"method"` + ID any `json:"id"` + Params map[string]any `json:"params"` +} + +func (r *jsonRPCRequest) stringParam(key string) string { + if v, ok := r.Params[key].(string); ok { + return v + } + return "" +} + +func (r *jsonRPCRequest) mapParam(key string) map[string]any { + if v, ok := r.Params[key].(map[string]any); ok { + return v + } + return nil +} + +func (r *jsonRPCRequest) stringMapParam(key string) map[string]string { + raw, ok := r.Params[key].(map[string]any) + if !ok { + return nil + } + out := make(map[string]string, len(raw)) + for k, v := range raw { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} diff --git a/authbridge/authlib/plugins/mcpparser_test.go b/authbridge/authlib/plugins/mcpparser_test.go new file mode 100644 index 000000000..4cd04dd8b --- /dev/null +++ b/authbridge/authlib/plugins/mcpparser_test.go @@ -0,0 +1,194 @@ +package plugins + +import ( + "context" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +func TestMCPParser_Capabilities(t *testing.T) { + p := NewMCPParser() + + if p.Name() != "mcp-parser" { + t.Errorf("Name() = %q, want %q", p.Name(), "mcp-parser") + } + + caps := p.Capabilities() + if !caps.BodyAccess { + t.Error("BodyAccess should be true") + } + if len(caps.Writes) != 1 || caps.Writes[0] != "mcp" { + t.Errorf("Writes = %v, want [mcp]", caps.Writes) + } +} + +func TestMCPParser_ToolCall(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"get_weather","arguments":{"city":"NYC"}}}`), + } + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP == nil { + t.Fatal("Extensions.MCP is nil") + } + if pctx.Extensions.MCP.Method != "tools/call" { + t.Errorf("Method = %q, want %q", pctx.Extensions.MCP.Method, "tools/call") + } + if pctx.Extensions.MCP.RPCID != float64(1) { + t.Errorf("RPCID = %v, want 1", pctx.Extensions.MCP.RPCID) + } + if pctx.Extensions.MCP.Tool == nil { + t.Fatal("Tool is nil") + } + if pctx.Extensions.MCP.Tool.Name != "get_weather" { + t.Errorf("Tool.Name = %q, want %q", pctx.Extensions.MCP.Tool.Name, "get_weather") + } + if pctx.Extensions.MCP.Tool.Args["city"] != "NYC" { + t.Errorf("Tool.Args[city] = %v, want %q", pctx.Extensions.MCP.Tool.Args["city"], "NYC") + } +} + +func TestMCPParser_ResourceRead(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","method":"resources/read","id":2,"params":{"uri":"file:///tmp/data.csv"}}`), + } + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP == nil { + t.Fatal("Extensions.MCP is nil") + } + if pctx.Extensions.MCP.Resource == nil { + t.Fatal("Resource is nil") + } + if pctx.Extensions.MCP.Resource.URI != "file:///tmp/data.csv" { + t.Errorf("Resource.URI = %q, want %q", pctx.Extensions.MCP.Resource.URI, "file:///tmp/data.csv") + } +} + +func TestMCPParser_PromptGet(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","method":"prompts/get","id":3,"params":{"name":"summarize","arguments":{"style":"brief"}}}`), + } + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP == nil { + t.Fatal("Extensions.MCP is nil") + } + if pctx.Extensions.MCP.Prompt == nil { + t.Fatal("Prompt is nil") + } + if pctx.Extensions.MCP.Prompt.Name != "summarize" { + t.Errorf("Prompt.Name = %q, want %q", pctx.Extensions.MCP.Prompt.Name, "summarize") + } + if pctx.Extensions.MCP.Prompt.Args["style"] != "brief" { + t.Errorf("Prompt.Args[style] = %q, want %q", pctx.Extensions.MCP.Prompt.Args["style"], "brief") + } +} + +func TestMCPParser_UnknownMethod(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","method":"notifications/initialized","id":4}`), + } + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP == nil { + t.Fatal("Extensions.MCP is nil") + } + if pctx.Extensions.MCP.Method != "notifications/initialized" { + t.Errorf("Method = %q, want %q", pctx.Extensions.MCP.Method, "notifications/initialized") + } + if pctx.Extensions.MCP.Tool != nil { + t.Error("Tool should be nil for unknown method") + } + if pctx.Extensions.MCP.Resource != nil { + t.Error("Resource should be nil for unknown method") + } + if pctx.Extensions.MCP.Prompt != nil { + t.Error("Prompt should be nil for unknown method") + } +} + +func TestMCPParser_NilBody(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{Body: nil} + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP != nil { + t.Error("Extensions.MCP should be nil when body is nil") + } +} + +func TestMCPParser_EmptyBody(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{Body: []byte{}} + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP != nil { + t.Error("Extensions.MCP should be nil when body is empty") + } +} + +func TestMCPParser_InvalidJSON(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{Body: []byte("not json")} + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP != nil { + t.Error("Extensions.MCP should be nil for invalid JSON") + } +} + +func TestMCPParser_MissingParams(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","method":"tools/call","id":5}`), + } + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP == nil { + t.Fatal("Extensions.MCP is nil") + } + if pctx.Extensions.MCP.Tool == nil { + t.Fatal("Tool should not be nil for tools/call") + } + if pctx.Extensions.MCP.Tool.Name != "" { + t.Errorf("Tool.Name = %q, want empty", pctx.Extensions.MCP.Tool.Name) + } +} + +func TestMCPParser_OnResponse(t *testing.T) { + p := NewMCPParser() + action := p.OnResponse(context.Background(), &pipeline.Context{}) + if action.Type != pipeline.Continue { + t.Errorf("OnResponse should return Continue, got %v", action.Type) + } +} diff --git a/authbridge/authlib/plugins/registry.go b/authbridge/authlib/plugins/registry.go index 0f6e69525..39aed031b 100644 --- a/authbridge/authlib/plugins/registry.go +++ b/authbridge/authlib/plugins/registry.go @@ -13,6 +13,7 @@ type PluginFactory func(a *auth.Auth) pipeline.Plugin var registry = map[string]PluginFactory{ "jwt-validation": func(a *auth.Auth) pipeline.Plugin { return NewJWTValidation(a) }, "token-exchange": func(a *auth.Auth) pipeline.Plugin { return NewTokenExchange(a) }, + "mcp-parser": func(_ *auth.Auth) pipeline.Plugin { return NewMCPParser() }, } // Build constructs a pipeline from an ordered list of plugin names. diff --git a/authbridge/cmd/authbridge/listener/extproc/server.go b/authbridge/cmd/authbridge/listener/extproc/server.go index 432917cfd..86e314b93 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server.go +++ b/authbridge/cmd/authbridge/listener/extproc/server.go @@ -5,10 +5,12 @@ package extproc import ( "encoding/json" + "log/slog" "net/http" "strings" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocfilterv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_proc/v3" extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" "google.golang.org/grpc/codes" @@ -17,6 +19,8 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) +const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes + // Server implements the Envoy ext_proc ExternalProcessor gRPC service. type Server struct { extprocv3.UnimplementedExternalProcessorServer @@ -27,6 +31,14 @@ type Server struct { // Process handles the bidirectional ext_proc stream. func (s *Server) Process(stream extprocv3.ExternalProcessor_ProcessServer) error { ctx := stream.Context() + + // pendingHeaders/pendingDirection hold state between RequestHeaders and + // RequestBody phases. Envoy guarantees sequential message ordering per + // stream: RequestBody always follows its RequestHeaders, and each stream + // is a single request — no interleaving or stale state is possible. + var pendingHeaders *corev3.HeaderMap + var pendingDirection string + for { select { case <-ctx.Done(): @@ -46,11 +58,35 @@ func (s *Server) Process(stream extprocv3.ExternalProcessor_ProcessServer) error headers := r.RequestHeaders.Headers direction := getHeader(headers, "x-authbridge-direction") + p := s.OutboundPipeline if direction == "inbound" { - resp = s.handleInbound(stream, headers) + p = s.InboundPipeline + } + + if p.NeedsBody() { + slog.Debug("ext_proc: requesting body from Envoy", "direction", direction) + pendingHeaders = headers + pendingDirection = direction + resp = requestBodyResponse() + } else if direction == "inbound" { + resp = s.handleInbound(stream, headers, nil) + } else { + resp = s.handleOutbound(stream, headers, nil) + } + + case *extprocv3.ProcessingRequest_RequestBody: + body := r.RequestBody.Body + slog.Debug("ext_proc: received request body", "direction", pendingDirection, "bodyLen", len(body)) + if len(body) > maxBodySize { + slog.Warn("ext_proc: request body too large", "direction", pendingDirection, "bodyLen", len(body)) + resp = immediateResponse(http.StatusRequestEntityTooLarge, "request body too large") + } else if pendingDirection == "inbound" { + resp = s.handleInbound(stream, pendingHeaders, body) } else { - resp = s.handleOutbound(stream, headers) + resp = s.handleOutbound(stream, pendingHeaders, body) } + pendingHeaders = nil + pendingDirection = "" case *extprocv3.ProcessingRequest_ResponseHeaders: resp = &extprocv3.ProcessingResponse{ @@ -69,12 +105,13 @@ func (s *Server) Process(stream extprocv3.ExternalProcessor_ProcessServer) error } } -func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer, headers *corev3.HeaderMap) *extprocv3.ProcessingResponse { +func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer, headers *corev3.HeaderMap, body []byte) *extprocv3.ProcessingResponse { ctx := stream.Context() pctx := &pipeline.Context{ Direction: pipeline.Inbound, Path: getHeader(headers, ":path"), Headers: headerMapToHTTP(headers), + Body: body, } action := s.InboundPipeline.Run(ctx, pctx) @@ -86,12 +123,13 @@ func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer, return allowResponse() } -func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer, headers *corev3.HeaderMap) *extprocv3.ProcessingResponse { +func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer, headers *corev3.HeaderMap, body []byte) *extprocv3.ProcessingResponse { ctx := stream.Context() pctx := &pipeline.Context{ Direction: pipeline.Outbound, Host: getHeader(headers, ":authority"), Headers: headerMapToHTTP(headers), + Body: body, } if pctx.Host == "" { pctx.Host = getHeader(headers, "host") @@ -128,6 +166,17 @@ func extractBearer(authHeader string) string { return "" } +func requestBodyResponse() *extprocv3.ProcessingResponse { + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{}, + }, + ModeOverride: &extprocfilterv3.ProcessingMode{ + RequestBodyMode: extprocfilterv3.ProcessingMode_BUFFERED, + }, + } +} + func allowResponse() *extprocv3.ProcessingResponse { return &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_RequestHeaders{ @@ -182,6 +231,18 @@ func denyResponse(code typev3.StatusCode, body string) *extprocv3.ProcessingResp } } +func immediateResponse(httpStatus int, reason string) *extprocv3.ProcessingResponse { + body, _ := json.Marshal(map[string]string{"error": reason}) + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extprocv3.ImmediateResponse{ + Status: &typev3.HttpStatus{Code: typev3.StatusCode(httpStatus)}, + Body: body, + }, + }, + } +} + func jsonError(errorCode, message string) string { b, _ := json.Marshal(map[string]string{"error": errorCode, "message": message}) return string(b) diff --git a/authbridge/cmd/authbridge/listener/extproc/server_test.go b/authbridge/cmd/authbridge/listener/extproc/server_test.go index 0efb16020..a5b5eb9fa 100644 --- a/authbridge/cmd/authbridge/listener/extproc/server_test.go +++ b/authbridge/cmd/authbridge/listener/extproc/server_test.go @@ -9,6 +9,7 @@ import ( "testing" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocfilterv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/ext_proc/v3" extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/grpc/metadata" @@ -16,6 +17,7 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" "github.com/kagenti/kagenti-extensions/authbridge/authlib/cache" "github.com/kagenti/kagenti-extensions/authbridge/authlib/exchange" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" "github.com/kagenti/kagenti-extensions/authbridge/authlib/routing" "github.com/kagenti/kagenti-extensions/authbridge/authlib/validation" @@ -348,3 +350,209 @@ func TestExtProc_ResponseHeaders(t *testing.T) { t.Fatal("expected ResponseHeaders passthrough") } } + +// --- Body Buffering Tests --- + +// bodyRecorderPlugin records whether it received a body during OnRequest. +type bodyRecorderPlugin struct { + receivedBody []byte +} + +func (p *bodyRecorderPlugin) Name() string { return "body-recorder" } +func (p *bodyRecorderPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{BodyAccess: true} +} +func (p *bodyRecorderPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + p.receivedBody = pctx.Body + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +func TestExtProc_BodyBuffering_Inbound(t *testing.T) { + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + + outbound, err := plugins.DefaultOutboundPipeline(auth.New(auth.Config{})) + if err != nil { + t.Fatal(err) + } + + srv := &Server{InboundPipeline: p, OutboundPipeline: outbound} + + body := []byte(`{"method":"tools/call","id":1,"params":{"name":"get_weather"}}`) + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + ":path", "/mcp", + )), + { + 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)) + } + + // First response should request body (mode override with BUFFERED) + first := stream.responses[0] + rh := first.GetRequestHeaders() + if rh == nil { + t.Fatal("first response should be HeadersResponse (requesting body)") + } + if first.ModeOverride == nil { + t.Fatal("expected ModeOverride requesting body buffering") + } + if first.ModeOverride.RequestBodyMode != extprocfilterv3.ProcessingMode_BUFFERED { + t.Errorf("RequestBodyMode = %v, want BUFFERED", first.ModeOverride.RequestBodyMode) + } + + // Second response should be the pipeline result (allow) + second := stream.responses[1] + if second.GetRequestHeaders() == nil && second.GetImmediateResponse() == nil { + t.Fatal("second response should be a pipeline result") + } + + // Plugin should have received the body + if string(recorder.receivedBody) != string(body) { + t.Errorf("plugin got body = %q, want %q", recorder.receivedBody, body) + } +} + +func TestExtProc_BodyBuffering_Outbound(t *testing.T) { + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + + inbound, err := plugins.DefaultInboundPipeline(auth.New(auth.Config{ + Verifier: &mockVerifier{claims: &validation.Claims{Subject: "user"}}, + Identity: auth.IdentityConfig{Audience: "test"}, + })) + if err != nil { + t.Fatal(err) + } + + srv := &Server{InboundPipeline: inbound, OutboundPipeline: p} + + body := []byte(`{"key":"value"}`) + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + outboundRequest(makeHeaders( + ":authority", "target-svc", + "authorization", "Bearer token", + )), + { + 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)) + } + + // First response requests body + if stream.responses[0].ModeOverride == nil { + t.Fatal("expected ModeOverride on first response") + } + + // Plugin should have received the body + if string(recorder.receivedBody) != string(body) { + t.Errorf("plugin got body = %q, want %q", recorder.receivedBody, body) + } +} + +func TestExtProc_BodyTooLarge(t *testing.T) { + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + + outbound, err := plugins.DefaultOutboundPipeline(auth.New(auth.Config{})) + if err != nil { + t.Fatal(err) + } + + srv := &Server{InboundPipeline: p, OutboundPipeline: outbound} + + bigBody := make([]byte, maxBodySize+1) + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + ":path", "/mcp", + )), + { + Request: &extprocv3.ProcessingRequest_RequestBody{ + RequestBody: &extprocv3.HttpBody{Body: bigBody}, + }, + }, + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) < 2 { + t.Fatalf("expected at least 2 responses, got %d", len(stream.responses)) + } + + // Second response should be an immediate 413 rejection + second := stream.responses[1] + ir := second.GetImmediateResponse() + if ir == nil { + t.Fatal("expected ImmediateResponse for oversized body") + } + if ir.Status.Code != 413 { + t.Errorf("status = %d, want 413", ir.Status.Code) + } +} + +func TestExtProc_NoBodyBuffering_WhenNotNeeded(t *testing.T) { + a := auth.New(auth.Config{ + Verifier: &mockVerifier{claims: &validation.Claims{Subject: "user"}}, + Identity: auth.IdentityConfig{Audience: "my-agent"}, + }) + srv := serverFromAuth(t, a) + + stream := &mockStream{ + ctx: context.Background(), + requests: []*extprocv3.ProcessingRequest{ + inboundRequest(makeHeaders( + "x-authbridge-direction", "inbound", + "authorization", "Bearer valid-token", + ":path", "/api/test", + )), + }, + } + + _ = srv.Process(stream) + + if len(stream.responses) != 1 { + t.Fatalf("expected 1 response (no body phase), got %d", len(stream.responses)) + } + // Should NOT have ModeOverride + if stream.responses[0].ModeOverride != nil { + t.Error("should not request body when pipeline doesn't need it") + } +} diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/cmd/authbridge/listener/forwardproxy/server.go index 83f94811b..f348dbe99 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server.go @@ -4,6 +4,7 @@ package forwardproxy import ( + "bytes" "encoding/json" "io" "log/slog" @@ -14,6 +15,8 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) +const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes + // Server is an HTTP forward proxy that performs token exchange on outbound requests. type Server struct { OutboundPipeline *pipeline.Pipeline @@ -51,6 +54,19 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { Headers: r.Header.Clone(), } + if s.OutboundPipeline.NeedsBody() && r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + body, err := io.ReadAll(r.Body) + if err != nil { + slog.Warn("forward-proxy: request body too large or unreadable", "host", r.Host, "error", err) + http.Error(w, `{"error":"request body too large"}`, http.StatusRequestEntityTooLarge) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + pctx.Body = body + slog.Debug("forward-proxy: buffered request body", "host", r.Host, "bodyLen", len(body)) + } + originalAuth := pctx.Headers.Get("Authorization") action := s.OutboundPipeline.Run(r.Context(), pctx) diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go index 83ca497ea..8b55157a3 100644 --- a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" @@ -137,3 +138,126 @@ func mustParseURL(rawURL string) *url.URL { } return u } + +// --- Body Buffering Tests --- + +// bodyRecorderPlugin records whether it received a body during OnRequest. +type bodyRecorderPlugin struct { + receivedBody []byte +} + +func (p *bodyRecorderPlugin) Name() string { return "body-recorder" } +func (p *bodyRecorderPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{BodyAccess: true} +} +func (p *bodyRecorderPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + p.receivedBody = pctx.Body + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +func TestForwardProxy_BodyBuffering(t *testing.T) { + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + body := `{"method":"tools/call","id":1,"params":{"name":"get_weather"}}` + req, _ := http.NewRequest("POST", backend.URL+"/mcp", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + if string(recorder.receivedBody) != body { + t.Errorf("plugin got body = %q, want %q", recorder.receivedBody, body) + } +} + +func TestForwardProxy_BodyTooLarge(t *testing.T) { + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("backend should not be reached for oversized body") + })) + defer backend.Close() + + srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + // Send body larger than maxBodySize (1MB) + bigBody := strings.Repeat("x", maxBodySize+1) + req, _ := http.NewRequest("POST", backend.URL+"/mcp", strings.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + + 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) + } + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Errorf("status = %d, want 413", resp.StatusCode) + } +} + +func TestForwardProxy_NoBodyBuffering_WhenNotNeeded(t *testing.T) { + a := auth.New(auth.Config{}) + p := outboundPipelineFromAuth(t, a) // default pipeline has no body-access plugins + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + srv := &Server{OutboundPipeline: p, Client: http.DefaultClient} + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + body := `{"data":"should not be buffered"}` + req, _ := http.NewRequest("POST", backend.URL+"/api", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + 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) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server.go b/authbridge/cmd/authbridge/listener/reverseproxy/server.go index aefccded9..e2c737b8e 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server.go @@ -4,7 +4,10 @@ package reverseproxy import ( + "bytes" "encoding/json" + "io" + "log/slog" "net/http" "net/http/httputil" "net/url" @@ -12,6 +15,8 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) +const maxBodySize = 1 << 20 // 1MB — matches Envoy's default per_stream_buffer_limit_bytes + // Server is an HTTP reverse proxy with inbound JWT validation. type Server struct { InboundPipeline *pipeline.Pipeline @@ -45,6 +50,19 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { Headers: r.Header.Clone(), } + if s.InboundPipeline.NeedsBody() && r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + body, err := io.ReadAll(r.Body) + if err != nil { + slog.Warn("reverse-proxy: request body too large or unreadable", "host", r.Host, "error", err) + http.Error(w, `{"error":"request body too large"}`, http.StatusRequestEntityTooLarge) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + pctx.Body = body + slog.Debug("reverse-proxy: buffered request body", "host", r.Host, "bodyLen", len(body)) + } + action := s.InboundPipeline.Run(r.Context(), pctx) if action.Type == pipeline.Reject { w.Header().Set("Content-Type", "application/json") diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go index 8672dca92..e00eae7a4 100644 --- a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go +++ b/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" @@ -104,3 +105,119 @@ func TestReverseProxy_BypassPath(t *testing.T) { t.Errorf("status = %d, want 200 for bypass path", resp.StatusCode) } } + +// --- Body Buffering Tests --- + +// bodyRecorderPlugin records whether it received a body during OnRequest. +type bodyRecorderPlugin struct { + receivedBody []byte +} + +func (p *bodyRecorderPlugin) Name() string { return "body-recorder" } +func (p *bodyRecorderPlugin) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{BodyAccess: true} +} +func (p *bodyRecorderPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + p.receivedBody = pctx.Body + return pipeline.Action{Type: pipeline.Continue} +} +func (p *bodyRecorderPlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +func TestReverseProxy_BodyBuffering(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer backend.Close() + + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + srv, err := NewServer(p, backend.URL) + if err != nil { + t.Fatal(err) + } + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + body := `{"method":"tools/call","id":1,"params":{"name":"get_weather"}}` + req, _ := http.NewRequest("POST", proxy.URL+"/mcp", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + if string(recorder.receivedBody) != body { + t.Errorf("plugin got body = %q, want %q", recorder.receivedBody, body) + } +} + +func TestReverseProxy_BodyTooLarge(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("backend should not be reached for oversized body") + })) + defer backend.Close() + + recorder := &bodyRecorderPlugin{} + p, err := pipeline.New([]pipeline.Plugin{recorder}) + if err != nil { + t.Fatal(err) + } + srv, err := NewServer(p, backend.URL) + if err != nil { + t.Fatal(err) + } + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + bigBody := strings.Repeat("x", maxBodySize+1) + req, _ := http.NewRequest("POST", proxy.URL+"/mcp", strings.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Errorf("status = %d, want 413", resp.StatusCode) + } +} + +func TestReverseProxy_BodyNotBuffered_WhenNotNeeded(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + a := auth.New(auth.Config{ + Verifier: &mockVerifier{claims: &validation.Claims{Subject: "user"}}, + Identity: auth.IdentityConfig{Audience: "test"}, + }) + srv, err := NewServer(inboundPipelineFromAuth(t, a), backend.URL) + if err != nil { + t.Fatal(err) + } + + proxy := httptest.NewServer(srv.Handler()) + defer proxy.Close() + + body := `{"data":"should not be buffered"}` + req, _ := http.NewRequest("POST", proxy.URL+"/api", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer valid-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} diff --git a/authbridge/cmd/authbridge/main.go b/authbridge/cmd/authbridge/main.go index f787b3f11..d4b68cbb0 100644 --- a/authbridge/cmd/authbridge/main.go +++ b/authbridge/cmd/authbridge/main.go @@ -115,6 +115,12 @@ func main() { log.Fatalf("building outbound pipeline: %v", err) } + if cfg.Mode == config.ModeWaypoint { + if inboundPipeline.NeedsBody() || outboundPipeline.NeedsBody() { + log.Fatalf("waypoint mode does not support plugins that require body access (ext_authz limitation)") + } + } + // Track servers for graceful shutdown var grpcServers []*grpc.Server var httpServers []*http.Server diff --git a/authbridge/demos/mcp-parser/README.md b/authbridge/demos/mcp-parser/README.md new file mode 100644 index 000000000..9fad23791 --- /dev/null +++ b/authbridge/demos/mcp-parser/README.md @@ -0,0 +1,218 @@ +# MCP Parser Plugin Demo + +This guide shows how to enable the `mcp-parser` plugin so AuthBridge +parses MCP JSON-RPC requests and logs tool calls, resource reads, and +prompt invocations. + +## Prerequisites + +- A running Kagenti cluster (Kind or OpenShift) with the Ansible installer + completed. The mcp-parser plugin works in `envoy-sidecar` and + `proxy-sidecar` modes on any cluster type. +- A namespace (e.g., `team1`) labeled with `kagenti-enabled: "true"` for + AuthBridge sidecar injection +- An MCP-based agent already deployed (e.g., the weather agent from + [demo-ui-advanced](../weather-agent/demo-ui-advanced.md)) + +## How It Works + +The `mcp-parser` plugin: +1. Declares `BodyAccess: true`, which triggers body buffering in all + listener modes +2. Parses the request body as JSON-RPC 2.0 +3. Populates `pctx.Extensions.MCP` with the parsed method, tool name, + resource URI, or prompt name +4. Returns `Continue` unconditionally (never rejects) + +In envoy-sidecar mode, AuthBridge uses ext_proc `ModeOverride` to +dynamically request the body from Envoy. No Envoy configuration changes +are needed — the default `request_body_mode: NONE` is overridden +per-stream when `mcp-parser` is in the pipeline. + +## Step 1: Patch the Runtime Config + +The `authbridge-runtime-config` ConfigMap in the agent namespace contains +the `config.yaml` that AuthBridge reads at startup. Add the `pipeline` +section: + +```bash +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: + name: authbridge-runtime-config + namespace: team1 +data: + config.yaml: | + mode: envoy-sidecar + inbound: + issuer: "http://keycloak.localtest.me:8080/realms/kagenti" + outbound: + keycloak_url: "http://keycloak-service.keycloak.svc:8080" + keycloak_realm: "kagenti" + default_policy: "passthrough" + identity: + type: "spiffe" + client_id_file: "/shared/client-id.txt" + client_secret_file: "/shared/client-secret.txt" + jwt_svid_path: "/opt/jwt_svid.token" + bypass: + inbound_paths: + - "/.well-known/*" + - "/healthz" + - "/readyz" + - "/livez" + pipeline: + inbound: + plugins: + - jwt-validation + - mcp-parser +EOF +``` + +> **Note**: The `pipeline` section is the only addition. All other fields +> should match your existing `authbridge-runtime-config`. If you use +> `client-secret` identity type instead of `spiffe`, adjust accordingly. + +The `mcp-parser` is placed **after** `jwt-validation` in the inbound +pipeline. This means: +- Unauthenticated requests are rejected before body buffering occurs +- Only validated requests have their body parsed (no wasted work) +- Future policy plugins can read both `pctx.Claims` and + `pctx.Extensions.MCP` + +## Step 2: Enable Debug Logging (Optional) + +To see all MCP parsing output, set `LOG_LEVEL=debug` on the authbridge +container. The easiest way: + +```bash +# If using the operator-injected sidecar, patch the ConfigMap: +kubectl patch configmap authbridge-config -n team1 \ + --type merge -p '{"data":{"LOG_LEVEL":"debug"}}' +``` + +Or toggle at runtime without restart: + +```bash +# Send SIGUSR1 to the authbridge process (PID 1 inside the container) +kubectl exec deploy/ -n team1 -c authbridge-proxy -- kill -USR1 1 +``` + +## Step 3: Restart the Agent Pod + +The config is read at startup, so restart the pod: + +```bash +kubectl rollout restart deploy/ -n team1 +``` + +Wait for the new pod to be ready: + +```bash +kubectl rollout status deploy/ -n team1 +``` + +## Step 4: Send a Request Through the Agent + +Use the Kagenti UI or curl to trigger a tool call through the agent. +For the weather agent: + +```bash +# Via the Kagenti UI: +# Navigate to the agent, type "What's the weather in NYC?" + +# Or via curl (replace TOKEN with a valid Keycloak access token): +curl -X POST http:///mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"get_weather","arguments":{"city":"NYC"}}}' +``` + +## Step 5: Verify in Logs + +Check the authbridge container logs for MCP parsing output: + +```bash +kubectl logs deploy/ -n team1 -c authbridge-proxy | grep mcp-parser +``` + +### Expected Output (LOG_LEVEL=info) + +When a tool call flows through: + +``` +level=INFO msg="mcp-parser: parsed tools/call" tool=get_weather +``` + +When a resource read flows through: + +``` +level=INFO msg="mcp-parser: parsed resources/read" uri=file:///tmp/data.csv +``` + +### Expected Output (LOG_LEVEL=debug) + +With debug enabled, you also see: + +``` +level=DEBUG msg="ext_proc: requesting body from Envoy" direction=inbound +level=DEBUG msg="ext_proc: received request body" direction=inbound bodyLen=87 +level=DEBUG msg="pipeline: plugin completed" plugin=jwt-validation +level=INFO msg="mcp-parser: parsed tools/call" tool=get_weather +level=DEBUG msg="pipeline: plugin completed" plugin=mcp-parser +``` + +### Requests That Are NOT MCP + +Non-JSON or non-MCP requests pass through silently at debug level: + +``` +level=DEBUG msg="mcp-parser: body is not valid JSON-RPC" error="invalid character..." bodyLen=42 +``` + +Or if the body is empty (e.g., GET requests): + +``` +level=DEBUG msg="mcp-parser: no body, skipping" +``` + +## Troubleshooting + +### No mcp-parser logs at all + +1. Confirm the ConfigMap was applied: + ```bash + kubectl get configmap authbridge-runtime-config -n team1 -o yaml | grep mcp-parser + ``` +2. Confirm the pod restarted after the ConfigMap change +3. Check authbridge startup logs for `"mode", "envoy-sidecar"` — if it + says the wrong mode, the config isn't being read + +### "waypoint mode does not support plugins that require body access" + +This fatal error means you configured `mcp-parser` in waypoint mode. +ext_authz cannot forward request bodies (hard Envoy constraint). Use +envoy-sidecar or proxy-sidecar mode instead. + +### Body not reaching the parser + +If you see `mcp-parser: no body, skipping` for requests that should +have a body, check: +1. The request is POST with a JSON body (GET requests have no body) +2. The `Content-Length` header is present (some clients omit it for + chunked encoding) +3. Envoy's `per_stream_buffer_limit_bytes` isn't set too low (default + 1MB is fine for MCP) + +## What This Enables (Future) + +With `pctx.Extensions.MCP` populated, future plugins can: + +- **tool-policy**: Allow/deny specific tools based on caller identity + (`pctx.Claims.Scopes` + `pctx.Extensions.MCP.Tool.Name`) +- **audit**: Log every tool invocation with full caller attribution +- **guardrails**: Inspect tool arguments for PII or injection patterns +- **rate-limit**: Per-tool rate limiting based on caller identity + +These are Phase 2/3 plugins that read the `mcp` extension slot.