diff --git a/common/chainkey/chainkey.go b/common/chainkey/chainkey.go new file mode 100644 index 000000000..5e0b164eb --- /dev/null +++ b/common/chainkey/chainkey.go @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package chainkey composes the key of a policy chain that belongs to one logical +// operation of a multiplexed API. +// +// It lives in common because both sides of the contract need it and neither can +// import the other: the gateway controller writes policy chains under these keys, +// and the policy engine composes the same key at request time to look one up. They +// are separate Go modules, and the engine's copy is under internal/, so a single +// shared construction is the only way the two cannot drift. A key that the engine +// composes and the controller never emitted is a request-time failure with no +// deploy-time signal, which is exactly the failure mode this package removes. +// +// The composition is a pure function of the operation. That is what makes two +// transports of one logical operation (A2A JSON-RPC and A2A HTTP+JSON) select the +// same chain without either being told about the other. +package chainkey + +import "strings" + +// Separator joins the key's components. ASCII US (unit separator, 0x1f) is used +// because it cannot appear in an HTTP-safe identifier — an API id, a vhost, or an +// operation name — so the join is unambiguous and, unlike a printable separator, +// needs no escaping rule. +// +// Contrast common/apikey's entity id, which joins on "_" and therefore has to guess +// the split with strings.LastIndex; that is ambiguous the moment a component +// contains the separator. +const Separator = "\x1f" + +// For composes the policy chain key for one operation of one routing partition. +// +// vhost alone represents the routing partition, so two routes that differ only by a +// header match would compose the same key. Callers that can produce such a pair must +// reject that configuration rather than let two partitions collide here. +func For(apiID, vhost, operation string) string { + return apiID + Separator + vhost + Separator + operation +} + +// Split decomposes a key produced by For. ok is false for anything that is not one — +// a route-key chain, or a malformed composed key. +// +// It lives here rather than at either call site because a caller that re-implements the +// split is a second place the format is encoded, which is the drift this package exists +// to prevent. The vhost is allowed to be empty (that is the default vhost); the API id +// and the operation are not. +func Split(key string) (apiID, vhost, operation string, ok bool) { + parts := strings.Split(key, Separator) + if len(parts) != 3 || parts[0] == "" || parts[2] == "" { + return "", "", "", false + } + return parts[0], parts[1], parts[2], true +} + +// IsComposed reports whether key was produced by For rather than being a route-key chain. +// A malformed key containing the separator counts as composed, so a caller can tell +// "not one of these" apart from "one of these, built wrong" and report the second. +func IsComposed(key string) bool { + return strings.Contains(key, Separator) +} + +// ValidComponent reports whether s can be used as a key component: non-empty and +// free of the separator. +// +// Operation identifiers are the case that matters. An API id and a vhost are +// server-derived, but an operation identifier can come from user-controlled space (an +// MCP tool name), and one containing the separator could otherwise compose the same +// key as a different (apiID, vhost, operation) triple. A resolver over such a space +// must reject an identifier this returns false for rather than escape it — escaping +// would need the same rule implemented identically on both sides again. +func ValidComponent(s string) bool { + return s != "" && !strings.Contains(s, Separator) +} diff --git a/common/chainkey/chainkey_test.go b/common/chainkey/chainkey_test.go new file mode 100644 index 000000000..047696560 --- /dev/null +++ b/common/chainkey/chainkey_test.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package chainkey + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestForIsStableAndSeparated(t *testing.T) { + key := For("api-1", "api.example.com", "message/send") + + assert.Equal(t, "api-1\x1fapi.example.com\x1fmessage/send", key) + assert.Equal(t, 3, len(strings.Split(key, Separator)), + "a key must split back into exactly its three components") + assert.Equal(t, key, For("api-1", "api.example.com", "message/send"), + "composition must be deterministic — the whole contract rests on it") +} + +// The convergence property this package exists for: two transports of one logical +// operation compose one key, because the key is a function of the operation and not +// of the route that carried it. +func TestBothTransportsComposeTheSameKey(t *testing.T) { + fromJSONRPC := For("api-1", "api.example.com", "message/send") + fromHTTPJSON := For("api-1", "api.example.com", "message/send") + + assert.Equal(t, fromJSONRPC, fromHTTPJSON) +} + +func TestDistinctInputsDoNotCollide(t *testing.T) { + keys := map[string]string{ + "base": For("api-1", "api.example.com", "message/send"), + "other-op": For("api-1", "api.example.com", "message/stream"), + "other-vhost": For("api-1", "sandbox.example.com", "message/send"), + "other-api": For("api-2", "api.example.com", "message/send"), + "shifted-fields": For("api-1", "api.example.com/message", "send"), + } + + seen := make(map[string]string, len(keys)) + for name, key := range keys { + if prior, dup := seen[key]; dup { + t.Fatalf("%s collides with %s", name, prior) + } + seen[key] = name + } +} + +// A printable separator would make "shifted-fields" above collide with "base". This +// pins the reason 0x1f was chosen, so a future change to a "/" or ":" separator has +// to fail a test rather than silently merge two operations' policies. +func TestSeparatorIsNotHTTPSafe(t *testing.T) { + assert.Equal(t, "\x1f", Separator) + assert.False(t, ValidComponent("tools/call"+Separator+"forged")) +} + +func TestValidComponent(t *testing.T) { + tests := []struct { + name string + input string + valid bool + }{ + {"plain", "message/send", true}, + {"with dots and dashes", "a2a.v1-send", true}, + {"empty", "", false}, + {"embedded separator", "tools/call\x1fother", false}, + {"leading separator", "\x1ftools/call", false}, + {"only separator", "\x1f", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.valid, ValidComponent(tc.input)) + }) + } +} + +func TestSplitRoundTrips(t *testing.T) { + apiID, vhost, operation, ok := Split(For("api-1", "api.example.com", "SendMessage")) + require.True(t, ok) + assert.Equal(t, "api-1", apiID) + assert.Equal(t, "api.example.com", vhost) + assert.Equal(t, "SendMessage", operation) + + // The default vhost is empty, and that is a valid partition rather than malformed. + apiID, vhost, operation, ok = Split(For("api-1", "", "GetTask")) + require.True(t, ok) + assert.Equal(t, "api-1", apiID) + assert.Empty(t, vhost) + assert.Equal(t, "GetTask", operation) +} + +func TestSplitRejectsNonKeys(t *testing.T) { + for name, key := range map[string]string{ + "route key": "GET|/pets|api.example.com", + "two components": "api-1" + Separator + "SendMessage", + "four components": "api-1" + Separator + "h" + Separator + "a" + Separator + "b", + "empty api id": Separator + "h" + Separator + "SendMessage", + "empty operation": "api-1" + Separator + "h" + Separator, + "empty string": "", + } { + t.Run(name, func(t *testing.T) { + _, _, _, ok := Split(key) + assert.False(t, ok) + }) + } +} + +// IsComposed has to answer "claims to be one of ours" rather than "is a valid one", +// so a caller can report a malformed key instead of silently treating it as a route key. +func TestIsComposed(t *testing.T) { + assert.True(t, IsComposed(For("api-1", "h", "SendMessage"))) + assert.True(t, IsComposed("api-1"+Separator+"malformed")) + assert.False(t, IsComposed("GET|/pets|h")) +} diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go index 04af1fce4..09578cde6 100644 --- a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go @@ -19,8 +19,11 @@ package models import ( + "encoding/json" + "fmt" "time" + "github.com/wso2/api-platform/common/chainkey" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) @@ -28,8 +31,13 @@ import ( // each transformer (RestAPI, LLM Provider, LLM Proxy). Both the Envoy xDS translator // and the policy xDS translator consume this struct. type RuntimeDeployConfig struct { - Metadata Metadata - Context string // API base path (e.g. "/weather/$version"); "" for kinds with no context + Metadata Metadata + Context string // API base path (e.g. "/weather/$version"); "" for kinds with no context + // PolicyChainResolver is the RDC-level default resolver name, used by every route + // that does not set Route.ResolverName. It is a compatibility default: every + // transformer shipping today sets it once and leaves the per-route field empty, so + // their emitted resolver_name is byte-identical to before per-route resolvers + // existed. Once every transformer writes the route field, this can be deprecated. PolicyChainResolver string // name of resolver registered in PE (e.g. "route-key", "mcp-tool") Routes map[string]*Route PolicyChains map[string]*PolicyChain @@ -77,6 +85,46 @@ type Route struct { // precedence (same path, method, and header-match count). Routes are emitted in // ascending Order so the stable route sorter preserves rule order for ties. Order int + + // ─── Policy chain resolution ───────────────────────────────────────────── + + // CanonicalChainKey is the key of the policy chain this route's requests use + // when the operation is determined by the route itself. It equals the route key + // for every kind shipping today, but it is emitted as its own explicit field + // rather than left implicit, because that is what lets a directly-resolved route be + // pointed at a *composed* operation key without a wire change. Empty means "same as + // the route key". + // + // A route naming a protocol resolver must leave this empty. Its key is derived from + // its own ResolverConfig by the resolver that owns the protocol, so a key here would + // be a second copy of the same fact with nothing to arbitrate between them — see + // ValidateResolution, which rejects the combination. + CanonicalChainKey string + + // ResolverName overrides RuntimeDeployConfig.PolicyChainResolver for this one + // route. It exists because one API can hold both shapes at once: routes whose + // operation is only knowable from the request name a protocol resolver, while the + // routes that are not operations at all — a well-known metadata document, a CORS + // preflight — stay directly resolved beside them. Empty inherits the RDC-level + // default. + ResolverName string + + // ResolverConfig is opaque, resolver-specific per-route configuration. The policy + // engine passes it to the resolver's Prepare hook once at xDS ingest, so a + // resolver that must compile a schema or build an index does it there rather than + // per request. Nil when the route's resolver needs none. + ResolverConfig json.RawMessage + + // MaxRequestBodyBytes is the largest request body, in wire bytes before any + // decompression, that the policy engine will accept for operation resolution on + // this route. Zero lets the engine apply its own low default. + // + // Only meaningful on a route whose resolver reads the body, and it is an acceptance + // ceiling rather than a buffering one: the engine checks it after Envoy has already + // buffered the body, so it bounds the unauthenticated decompression and parsing work + // on that route, not the memory a caller can make the gateway hold. That is bounded + // listener-wide by per_connection_buffer_limit_bytes. + MaxRequestBodyBytes int64 } // RouteTimeout holds parsed timeout values for a route. @@ -140,3 +188,167 @@ type UpstreamTLS struct { type ConfigTransformer interface { Transform(cfg *StoredConfig) (*RuntimeDeployConfig, error) } + +// RouteKeyResolverName is the resolver name meaning "the route determines the +// operation" — the identity case. It must match the policy engine's +// resolver.RouteKeyResolverName; the two are separate constants because the +// controller and the runtime are separate modules that agree on a wire value. +const RouteKeyResolverName = "route-key" + +// EffectiveResolverName returns the resolver this route actually uses: its own +// override, or the RDC-level compatibility default. This is the single place the +// precedence is expressed, so the wire value and any validation of it cannot disagree. +func (rdc *RuntimeDeployConfig) EffectiveResolverName(route *Route) string { + if route.ResolverName != "" { + return route.ResolverName + } + return rdc.PolicyChainResolver +} + +// EffectiveCanonicalChainKey returns the chain key for a route resolved by identity, +// falling back to the route key when the transformer left it unset. +func (rdc *RuntimeDeployConfig) EffectiveCanonicalChainKey(routeKey string, route *Route) string { + if route.CanonicalChainKey != "" { + return route.CanonicalChainKey + } + return routeKey +} + +// IsDirectlyResolved reports whether a resolver name means "the route itself determines +// the chain key", so the route carries that key rather than deriving one per request. An +// empty name does, because that is what every RDC looked like before resolvers existed. +// +// Exported because the snapshot translator decides from it whether to put +// canonical_chain_key on the wire at all, and that decision has to agree with the +// validation rule below — two predicates could disagree. +func IsDirectlyResolved(name string) bool { + return name == "" || name == RouteKeyResolverName +} + +// ValidateResolution checks that a RuntimeDeployConfig's chain references actually +// resolve, and must pass before the RDC is stored or published. +// +// It exists because the RouteConfig and PolicyChain resources travel to the policy +// engine on two independent xDS streams: a route that reaches a chain key which was +// never built produces a deployment that looks accepted and then fails — or, worse, +// silently applies no policy — on every request to that operation. Catching a +// controller construction error here turns a runtime mystery into a deploy-time error +// naming the route. +// +// Under composed keys there is no operation map to validate. The failure mode moved +// from "the map points at a missing chain" to "a key the engine will compose has no +// chain", so the checks moved with it: +// +// - a directly-resolved route's canonical key must name a chain (including one pointed +// at a composed operation key, whose key must resolve like any other); +// - a resolver-bearing route must not carry a canonical key, and must have at least +// one operation chain in its own partition — otherwise no request to it can ever +// resolve; +// - every composed chain key must be well formed and belong to this RDC. +// +// Exhaustiveness over a *closed* operation set — "a chain exists for every operation +// the protocol defines" — needs the protocol's operation enum and lands with the first +// resolver that has one. It is not expressible here for an open set (an MCP tool name +// is deployment data), which is why the generic check is reachability, not completeness. +func (rdc *RuntimeDeployConfig) ValidateResolution() error { + // One pass over the chains: validate every composed key and collect which + // partitions have operation chains, so the per-route check below is a map lookup + // rather than a scan of every chain per route. + partitionsWithOperationChains := make(map[string]struct{}) + for chainKey, chain := range rdc.PolicyChains { + // A present key with a nil value passes every reachability check below and then + // panics the snapshot translator, which dereferences it to read Policies. A chain + // with no policies is legitimate and common (an operation whose policies are all + // inherited); a nil one is a construction mistake, and this is the layer that is + // supposed to name it. + if chain == nil { + return fmt.Errorf("policy chain %q is nil", chainKey) + } + if !chainkey.IsComposed(chainKey) { + continue // a route-key chain, not a composed one + } + apiID, vhost, _, ok := chainkey.Split(chainKey) + if !ok { + return fmt.Errorf("policy chain key %q is not a well-formed composed key (apiID, vhost, operation)", + chainKey) + } + if apiID != rdc.Metadata.UUID { + return fmt.Errorf("policy chain key %q is composed for API %q, not this API (%q)", + chainKey, apiID, rdc.Metadata.UUID) + } + partitionsWithOperationChains[vhost] = struct{}{} + } + + for routeKey, route := range rdc.Routes { + if route == nil { + return fmt.Errorf("route %q: nil route", routeKey) + } + + resolverName := rdc.EffectiveResolverName(route) + + if IsDirectlyResolved(resolverName) { + canonical := rdc.EffectiveCanonicalChainKey(routeKey, route) + if _, ok := rdc.PolicyChains[canonical]; !ok { + return fmt.Errorf("route %q: canonical chain key %q names no policy chain", routeKey, canonical) + } + // Existing is not enough. A directly-resolved route may be pointed at a + // composed operation key, and a chain composed for another routing partition + // is a perfectly valid chain that belongs to someone else: + // a production route pointed at a sandbox operation chain would run the + // sandbox's authentication, authorization and rate limits. Existence checks + // catch a missing chain; only this catches the wrong one. + if chainkey.IsComposed(canonical) { + apiID, vhost, _, ok := chainkey.Split(canonical) + if !ok { + return fmt.Errorf("route %q: canonical chain key %q is not a well-formed composed key", + routeKey, canonical) + } + if apiID != rdc.Metadata.UUID { + return fmt.Errorf("route %q: canonical chain key %q belongs to API %q, not this API (%q)", + routeKey, canonical, apiID, rdc.Metadata.UUID) + } + if vhost != route.Vhost { + return fmt.Errorf( + "route %q: canonical chain key %q belongs to routing partition (vhost) %q, but the route serves %q", + routeKey, canonical, vhost, route.Vhost) + } + } else if canonical != routeKey { + // A composed operation key is the one redirect an identity route may + // carry. Any other key that merely happens to exist is refused, because + // the failure it hides is silent: a route pointed at another route's + // chain — a public route carrying "GET|/admin|h", say — passes the + // existence check above and then runs that route's authentication and + // rate limits instead of its own. Same class as the cross-partition case, + // without a composed key's structure to detect it from. + return fmt.Errorf( + "route %q: canonical chain key %q is neither the route key nor a composed operation key", + routeKey, canonical) + } + continue + } + + // A resolver-bearing route resolves its chain per request, so a canonical key + // on it would be read by nothing — it is a construction mistake, not dead + // weight to tolerate. + if route.CanonicalChainKey != "" { + return fmt.Errorf( + "route %q: resolver %q composes its chain key per request and must not carry a canonical chain key (%q)", + routeKey, resolverName, route.CanonicalChainKey) + } + if _, ok := partitionsWithOperationChains[route.Vhost]; !ok { + return fmt.Errorf( + "route %q: resolver %q has no operation chains in its routing partition (vhost %q), so no request to it can resolve", + routeKey, resolverName, route.Vhost) + } + } + return nil +} + +// ChainKeyFor composes the policy chain key for one operation of this RDC, for the +// given routing partition. Transformers that build operation chains must key them with +// this rather than formatting the string themselves: the policy engine composes the +// same key at request time from the same shared helper, and a chain emitted under any +// other spelling is one it will never find. +func (rdc *RuntimeDeployConfig) ChainKeyFor(vhost, operation string) string { + return chainkey.For(rdc.Metadata.UUID, vhost, operation) +} diff --git a/gateway/gateway-controller/pkg/policyxds/manager.go b/gateway/gateway-controller/pkg/policyxds/manager.go index 8f1bec747..580071478 100644 --- a/gateway/gateway-controller/pkg/policyxds/manager.go +++ b/gateway/gateway-controller/pkg/policyxds/manager.go @@ -84,11 +84,24 @@ func (pm *PolicyManager) DeleteAPIConfig(kind, handle string) error { } // AddRuntimeConfig adds or updates a RuntimeDeployConfig and triggers snapshot update. +// +// Resolution references are validated *before* the RDC is stored, so a construction +// error cannot reach either xDS stream. The two streams are independent: a route +// pointing at a chain key that was never built would otherwise produce a deployment +// that looks accepted and then fails — or silently applies no policy — on every +// request to that operation. func (pm *PolicyManager) AddRuntimeConfig(key string, rdc *models.RuntimeDeployConfig) error { if pm.runtimeStore == nil { return fmt.Errorf("runtime config store not configured") } + if err := rdc.ValidateResolution(); err != nil { + pm.logger.Error("Rejecting runtime deploy config with unresolvable policy chain references", + slog.String("key", key), + slog.Any("error", err)) + return fmt.Errorf("invalid policy chain resolution for %s: %w", key, err) + } + pm.runtimeStore.Set(key, rdc) pm.logger.Info("Runtime deploy config added", diff --git a/gateway/gateway-controller/pkg/policyxds/route_resolution_test.go b/gateway/gateway-controller/pkg/policyxds/route_resolution_test.go new file mode 100644 index 000000000..97344e462 --- /dev/null +++ b/gateway/gateway-controller/pkg/policyxds/route_resolution_test.go @@ -0,0 +1,634 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package policyxds + +import ( + "encoding/json" + "log/slog" + "os" + "sort" + "testing" + + "github.com/envoyproxy/go-control-plane/pkg/cache/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/wso2/api-platform/common/chainkey" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" +) + +// newTestRuntimeStore is a plain in-memory RuntimeConfigStore, so the tests below +// assert on what actually reached storage. +func newTestRuntimeStore() *storage.RuntimeConfigStore { + return storage.NewRuntimeConfigStore() +} + +func testTranslator() *Translator { + return NewTranslator(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))) +} + +// decodeRouteConfig unwraps an emitted RouteConfig resource back into the map the +// policy engine's xDS handler parses, so a test asserts on exactly what goes on the +// wire rather than on an intermediate struct. +func decodeRouteConfig(t *testing.T, res types.Resource) map[string]interface{} { + t.Helper() + outer, ok := res.(*anypb.Any) + require.True(t, ok, "route config resource must be an Any") + + s := &structpb.Struct{} + require.NoError(t, proto.Unmarshal(outer.Value, s)) + return s.AsMap() +} + +// restRDC is a RuntimeDeployConfig shaped exactly the way RestAPITransformer builds +// one: an RDC-level "route-key" resolver, no per-route resolution fields at all. +func restRDC() *models.RuntimeDeployConfig { + return &models.RuntimeDeployConfig{ + Metadata: models.Metadata{ + UUID: "api-1", Kind: "RestApi", Handle: "petstore", + Version: "v1", DisplayName: "Petstore", ProjectID: "proj-1", + }, + Context: "/petstore", + PolicyChainResolver: models.RouteKeyResolverName, + Routes: map[string]*models.Route{ + "GET|/petstore/v1/pets|localhost": { + Method: "GET", Path: "/petstore/v1/pets", OperationPath: "/pets", + Vhost: "localhost", + Upstream: models.RouteUpstream{ClusterKey: "upstream_main"}, + }, + "POST|/petstore/v1/pets|localhost": { + Method: "POST", Path: "/petstore/v1/pets", OperationPath: "/pets", + Vhost: "localhost", + Upstream: models.RouteUpstream{ClusterKey: "upstream_main"}, + }, + }, + PolicyChains: map[string]*models.PolicyChain{ + "GET|/petstore/v1/pets|localhost": {Policies: []models.Policy{{Name: "jwt-auth", Version: "v1"}}}, + "POST|/petstore/v1/pets|localhost": {Policies: []models.Policy{{Name: "jwt-auth", Version: "v1"}}}, + }, + UpstreamClusters: map[string]*models.UpstreamCluster{ + "upstream_main": {BasePath: "/", Endpoints: []models.Endpoint{{Host: "localhost", Port: 8080}}}, + }, + } +} + +// ─── Invariant 5.2: no unintended RouteConfig churn ────────────────────────── + +// An existing kind's RouteConfig resource must gain exactly one field — +// canonical_chain_key — and nothing else. Anything more re-versions every +// RouteConfig resource on every gateway at upgrade for no behavioural reason; a field +// serialising as {} or 0 is the usual way that happens. +func TestExistingKindGainsOnlyCanonicalChainKey(t *testing.T) { + // The exact field set a pre-resolution controller emitted for a REST route. + before := []string{ + "route_key", "metadata", "resolver_name", + "upstream_base_path", "upstream_definition_paths", + } + + resources, err := testTranslator().TranslateRuntimeConfigs([]*models.RuntimeDeployConfig{restRDC()}) + require.NoError(t, err) + + routes := resources[RouteConfigTypeURL] + require.Len(t, routes, 2) + + for routeKey, res := range routes { + data := decodeRouteConfig(t, res) + + got := make([]string, 0, len(data)) + for k := range data { + got = append(got, k) + } + sort.Strings(got) + + want := append([]string{}, before...) + want = append(want, "canonical_chain_key") + sort.Strings(want) + + assert.Equal(t, want, got, + "route %q must emit the pre-change field set plus canonical_chain_key and nothing else", routeKey) + + // For an identity route the new field's value is the route key, so nothing + // about which chain is selected changes. + assert.Equal(t, routeKey, data["canonical_chain_key"]) + assert.Equal(t, models.RouteKeyResolverName, data["resolver_name"]) + } +} + +// The omission rule, stated directly: these three fields must be absent — not +// present-and-empty — on a route that does not need request-time resolution. +func TestEmptyResolutionFieldsAreOmitted(t *testing.T) { + resources, err := testTranslator().TranslateRuntimeConfigs([]*models.RuntimeDeployConfig{restRDC()}) + require.NoError(t, err) + + for routeKey, res := range resources[RouteConfigTypeURL] { + data := decodeRouteConfig(t, res) + // operation_map is checked too: the field is gone from the contract entirely, so + // a route emitting one would mean a stale code path is still writing it. + for _, field := range []string{"operation_map", "resolver_config", "max_request_body_bytes"} { + _, present := data[field] + assert.False(t, present, "route %q must omit %q when unset, not emit an empty value", routeKey, field) + } + } +} + +// Golden test: the complete emitted content for an existing kind's route, pinned +// value by value. It is a content comparison rather than a byte comparison on purpose +// — the resource bytes are produced by anypb.New over a Struct whose map fields have +// no defined wire order, and the LinearCache re-versions every resource it is handed +// regardless ("we assume all resources passed to SetResources are changed"), so byte +// stability is neither achievable nor what protects behaviour here. What protects it is +// that the *content* an existing kind emits is exactly what it was, plus the one new +// field whose value is the route key. +func TestExistingKindGoldenRouteConfigContent(t *testing.T) { + resources, err := testTranslator().TranslateRuntimeConfigs([]*models.RuntimeDeployConfig{restRDC()}) + require.NoError(t, err) + + const routeKey = "GET|/petstore/v1/pets|localhost" + got := decodeRouteConfig(t, resources[RouteConfigTypeURL][routeKey]) + + assert.Equal(t, map[string]interface{}{ + "route_key": routeKey, + "metadata": map[string]interface{}{ + "uuid": "api-1", + "kind": "RestApi", + "handle": "petstore", + "version": "v1", + "display_name": "Petstore", + "project_id": "proj-1", + "api_context": "/petstore", + "vhost": "localhost", + "path": "/pets", + }, + "resolver_name": models.RouteKeyResolverName, + "upstream_base_path": "/", + "upstream_definition_paths": map[string]interface{}{}, + // The only addition. Equal to the route key, so which chain gets selected is + // unchanged for every kind shipping today. + "canonical_chain_key": routeKey, + }, got) +} + +// ─── Per-route resolver selection ──────────────────────────────────────────── + +// The reason resolver selection moved onto the route: one API can hold both shapes at +// once. Routes that need request-time resolution name a protocol resolver and are +// configured per route; routes that do not stay ordinary directly-resolved routes with +// their own chain key. +// +// No real protocol resolver exists yet, so this uses a placeholder name and asserts only +// the *wire shape* the model already enforces. What it pins is the pairing rule: a route +// naming a protocol resolver carries `resolver_config` and no `canonical_chain_key`, +// because the resolver's own configuration is the single source of the key and a second +// copy could disagree with nothing to arbitrate. +func TestProtocolResolvedRoutesCarryConfigAndNoChainKey(t *testing.T) { + const resolverName = "fake-protocol" + + rdc := &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-2", Kind: "RestApi", Handle: "svc", Version: "v1"}, + Context: "/svc", + // The RDC-level default stays directly-resolved; only the routes that need + // request-time resolution override it. + PolicyChainResolver: models.RouteKeyResolverName, + Routes: map[string]*models.Route{ + // Many operations on one HTTP route: the operation is only knowable from the + // request, so the resolver composes a key per request. + "POST|/svc/v1/rpc|localhost": { + Method: "POST", Path: "/svc/v1/rpc", Vhost: "localhost", + ResolverName: resolverName, + ResolverConfig: json.RawMessage(`{"mode":"multiplexed"}`), + MaxRequestBodyBytes: 65536, + Upstream: models.RouteUpstream{ClusterKey: "upstream_main"}, + }, + // One route per operation, named in the route's own config. Same resolver, + // different configuration — which is what makes two routes of one resolver + // independent rather than forcing one shape on both. + "POST|/svc/v1/op-one|localhost": { + Method: "POST", Path: "/svc/v1/op-one", Vhost: "localhost", + ResolverName: resolverName, + ResolverConfig: json.RawMessage(`{"mode":"single","operation":"OperationOne"}`), + Upstream: models.RouteUpstream{ClusterKey: "upstream_main"}, + }, + // Needs no resolution at all, so it keeps its own route-key chain. + "GET|/svc/v1/status|localhost": { + Method: "GET", Path: "/svc/v1/status", Vhost: "localhost", + Upstream: models.RouteUpstream{ClusterKey: "upstream_main"}, + }, + }, + PolicyChains: map[string]*models.PolicyChain{ + chainkey.For("api-2", "localhost", "OperationOne"): {Policies: []models.Policy{{Name: "jwt-auth", Version: "v1"}}}, + chainkey.For("api-2", "localhost", "OperationTwo"): {Policies: []models.Policy{{Name: "jwt-auth", Version: "v1"}}}, + "GET|/svc/v1/status|localhost": {}, + }, + UpstreamClusters: map[string]*models.UpstreamCluster{ + "upstream_main": {BasePath: "/", Endpoints: []models.Endpoint{{Host: "localhost", Port: 8080}}}, + }, + } + + // A regression test for the pairing rule: emitting a canonical key beside + // resolver_config would make the controller reject its own artifact. + require.NoError(t, rdc.ValidateResolution()) + + resources, err := testTranslator().TranslateRuntimeConfigs([]*models.RuntimeDeployConfig{rdc}) + require.NoError(t, err) + routes := resources[RouteConfigTypeURL] + require.Len(t, routes, 3) + + multiplexed := decodeRouteConfig(t, routes["POST|/svc/v1/rpc|localhost"]) + assert.Equal(t, resolverName, multiplexed["resolver_name"]) + assert.Equal(t, map[string]interface{}{"mode": "multiplexed"}, multiplexed["resolver_config"]) + assert.Equal(t, float64(65536), multiplexed["max_request_body_bytes"]) + _, hasMap := multiplexed["operation_map"] + assert.False(t, hasMap, "there is no operation map on the wire under composed keys") + + perOperation := decodeRouteConfig(t, routes["POST|/svc/v1/op-one|localhost"]) + assert.Equal(t, resolverName, perOperation["resolver_name"]) + assert.Equal(t, map[string]interface{}{"mode": "single", "operation": "OperationOne"}, + perOperation["resolver_config"]) + + for routeKey, data := range map[string]map[string]interface{}{ + "POST|/svc/v1/rpc|localhost": multiplexed, + "POST|/svc/v1/op-one|localhost": perOperation, + } { + _, hasKey := data["canonical_chain_key"] + assert.False(t, hasKey, + "route %q names a protocol resolver, so its key comes from resolver_config alone", routeKey) + } + + status := decodeRouteConfig(t, routes["GET|/svc/v1/status|localhost"]) + assert.Equal(t, models.RouteKeyResolverName, status["resolver_name"]) + assert.Equal(t, "GET|/svc/v1/status|localhost", status["canonical_chain_key"], + "a route that needs no resolution still carries its own key") +} + +func TestEffectiveResolverName(t *testing.T) { + rdc := &models.RuntimeDeployConfig{PolicyChainResolver: models.RouteKeyResolverName} + + assert.Equal(t, models.RouteKeyResolverName, rdc.EffectiveResolverName(&models.Route{}), + "an empty route override inherits the RDC default") + assert.Equal(t, "fake-multiplexed", rdc.EffectiveResolverName(&models.Route{ResolverName: "fake-multiplexed"})) + + // An RDC that sets no default at all still emits the empty value the policy engine + // treats as identity, so nothing changes for a transformer that never set it. + empty := &models.RuntimeDeployConfig{} + assert.Equal(t, "", empty.EffectiveResolverName(&models.Route{})) +} + +// ─── Invariant 5.5: referential integrity ──────────────────────────────────── + +func TestValidateResolution(t *testing.T) { + chains := func(keys ...string) map[string]*models.PolicyChain { + m := make(map[string]*models.PolicyChain, len(keys)) + for _, k := range keys { + m[k] = &models.PolicyChain{} + } + return m + } + + tests := []struct { + name string + rdc *models.RuntimeDeployConfig + wantErr string + }{ + { + name: "identity route with its own chain", + rdc: &models.RuntimeDeployConfig{ + PolicyChainResolver: models.RouteKeyResolverName, + Routes: map[string]*models.Route{"GET|/pets|h": {}}, + PolicyChains: chains("GET|/pets|h"), + }, + }, + { + name: "identity route with an explicit canonical key equal to its route key", + rdc: &models.RuntimeDeployConfig{ + Routes: map[string]*models.Route{"GET|/pets|h": {CanonicalChainKey: "GET|/pets|h"}}, + PolicyChains: chains("GET|/pets|h"), + }, + }, + { + // A chain that exists but is neither this route's key nor a composed operation + // key. Existence alone would accept it, and the route would then run whatever + // policies that chain carries — the borrowed-policies failure is silent, so it + // has to be refused here. + name: "identity route pointed at an arbitrary existing chain", + rdc: &models.RuntimeDeployConfig{ + Routes: map[string]*models.Route{"GET|/pets|h": {CanonicalChainKey: "shared-chain"}}, + PolicyChains: chains("GET|/pets|h", "shared-chain"), + }, + wantErr: `is neither the route key nor a composed operation key`, + }, + { + // The concrete shape of that mistake: a public route carrying another route's + // key, which would silently borrow that route's authentication. + name: "identity route pointed at another route's chain", + rdc: &models.RuntimeDeployConfig{ + Routes: map[string]*models.Route{ + "GET|/pets|h": {CanonicalChainKey: "GET|/admin|h"}, + "GET|/admin|h": {}, + }, + PolicyChains: chains("GET|/pets|h", "GET|/admin|h"), + }, + wantErr: `canonical chain key "GET|/admin|h" is neither the route key nor a composed operation key`, + }, + { + // A directly-resolved route deliberately pointed at a composed operation key: + // resolution still comes from the route, but the chain it names is an + // operation's rather than its own route key. Accepted because the key is well + // formed and belongs to this API and this route's vhost — the checks the two + // rejection cases above exercise. + name: "directly-resolved route pointed at a composed operation key", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/v1/op-one|h": { + Vhost: "h", + CanonicalChainKey: chainkey.For("api-1", "h", "OperationOne"), + }}, + PolicyChains: chains(chainkey.For("api-1", "h", "OperationOne")), + }, + }, + { + name: "resolver-bearing route with operation chains in its partition", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/rpc|h": { + Vhost: "h", + ResolverName: "fake-multiplexed", + }}, + PolicyChains: chains( + chainkey.For("api-1", "h", "OperationOne"), + chainkey.For("api-1", "h", "GetTask"), + ), + }, + }, + { + // The failure this validation exists for: the two xDS streams are + // independent, so a route that can never reach a chain would otherwise + // surface only at request time. + name: "resolver-bearing route with no operation chains at all", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/rpc|h": { + Vhost: "h", + ResolverName: "fake-multiplexed", + }}, + PolicyChains: chains("GET|/pets|h"), + }, + wantErr: `has no operation chains in its routing partition (vhost "h")`, + }, + { + // Chains exist, but for a different partition — every request to this route + // would compose a key from its own vhost and find nothing. + name: "operation chains in the wrong routing partition", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/rpc|sandbox": { + Vhost: "sandbox", + ResolverName: "fake-multiplexed", + }}, + PolicyChains: chains(chainkey.For("api-1", "main", "OperationOne")), + }, + wantErr: `no operation chains in its routing partition (vhost "sandbox")`, + }, + { + // A resolver-bearing route composes its key per request, so a canonical key + // on it would be read by nothing. + name: "resolver-bearing route carrying a canonical chain key", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/rpc|h": { + Vhost: "h", + ResolverName: "fake-multiplexed", + CanonicalChainKey: chainkey.For("api-1", "h", "OperationOne"), + }}, + PolicyChains: chains(chainkey.For("api-1", "h", "OperationOne")), + }, + wantErr: "must not carry a canonical chain key", + }, + { + // Catches a transformer that composed with the wrong field order or dropped + // a component: the engine would compose three parts and never match. + name: "malformed composed chain key", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"GET|/pets|h": {CanonicalChainKey: "api-1\x1fmessage/send"}}, + PolicyChains: chains("api-1\x1fmessage/send"), + }, + wantErr: "is not a well-formed composed key", + }, + { + name: "composed chain key belonging to another API", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/rpc|h": { + Vhost: "h", + ResolverName: "fake-multiplexed", + }}, + PolicyChains: chains(chainkey.For("api-2", "h", "OperationOne")), + }, + wantErr: `is composed for API "api-2", not this API ("api-1")`, + }, + { + // The default vhost is the empty string, and that is a legitimate partition + // rather than a malformed key. + name: "default vhost composes a valid key", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{"POST|/rpc|": { + ResolverName: "fake-multiplexed", + }}, + PolicyChains: chains(chainkey.For("api-1", "", "OperationOne")), + }, + }, + { + // Existence is not enough: this chain is real, well-formed, and belongs to + // this API — it just belongs to a different routing partition. A production + // route pointed at a sandbox operation chain would run the sandbox's + // authentication, authorization and rate limits. + name: "identity route redirected into another partition's chain", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{ + "POST|/v1/op-one|prod": { + Vhost: "prod", + CanonicalChainKey: chainkey.For("api-1", "sandbox", "OperationOne"), + }, + }, + PolicyChains: chains(chainkey.For("api-1", "sandbox", "OperationOne")), + }, + wantErr: `belongs to routing partition (vhost) "sandbox", but the route serves "prod"`, + }, + { + // Same shape, across APIs. The chains pass would reject this chain on its own, + // but the route-level check must name the route, since that is what is wrong. + name: "identity route redirected into another API's chain", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{ + "POST|/v1/op-one|h": { + Vhost: "h", + CanonicalChainKey: chainkey.For("api-2", "h", "OperationOne"), + }, + }, + PolicyChains: chains(chainkey.For("api-2", "h", "OperationOne")), + }, + wantErr: `is composed for API "api-2"`, + }, + { + // The default vhost is the empty string on both sides, so it must match rather + // than be treated as "unset, therefore anything goes". + name: "default-vhost route matches a default-vhost chain", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{ + "POST|/v1/op-one|": {CanonicalChainKey: chainkey.For("api-1", "", "OperationOne")}, + }, + PolicyChains: chains(chainkey.For("api-1", "", "OperationOne")), + }, + }, + { + name: "default-vhost route must not reach a named-vhost chain", + rdc: &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1"}, + Routes: map[string]*models.Route{ + "POST|/v1/op-one|": {CanonicalChainKey: chainkey.For("api-1", "prod", "OperationOne")}, + }, + PolicyChains: chains(chainkey.For("api-1", "prod", "OperationOne")), + }, + wantErr: `but the route serves ""`, + }, + { + // A present key with a nil value passes every reachability check — the key is + // there — and then panics the snapshot translator, which dereferences it to + // read Policies. Deploy time is where it has to be named. + name: "nil policy chain value", + rdc: &models.RuntimeDeployConfig{ + Routes: map[string]*models.Route{"GET|/pets|h": {}}, + PolicyChains: map[string]*models.PolicyChain{"GET|/pets|h": nil}, + }, + wantErr: `policy chain "GET|/pets|h" is nil`, + }, + { + // The contrast that keeps the check honest: a chain with no policies is + // legitimate and common (an operation whose policies are all inherited). + name: "empty policy chain is valid", + rdc: &models.RuntimeDeployConfig{ + Routes: map[string]*models.Route{"GET|/pets|h": {}}, + PolicyChains: map[string]*models.PolicyChain{"GET|/pets|h": {}}, + }, + }, + { + name: "nil route", + rdc: &models.RuntimeDeployConfig{ + Routes: map[string]*models.Route{"GET|/pets|h": nil}, + PolicyChains: chains("GET|/pets|h"), + }, + wantErr: "nil route", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.rdc.ValidateResolution() + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// An RDC that fails validation must not reach the store, so neither xDS stream can +// ever publish it. +func TestAddRuntimeConfigRejectsUnresolvableReferences(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + sm := NewSnapshotManager(logger) + pm := NewPolicyManager(sm, logger) + + store := newTestRuntimeStore() + sm.SetRuntimeStore(store) + pm.SetRuntimeStore(store) + + bad := &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "agent-1", Kind: "Agent", Handle: "assistant"}, + Routes: map[string]*models.Route{"POST|/rpc|h": { + Vhost: "h", + ResolverName: "fake-multiplexed", + }}, + // No operation chains were built, so no request to this route could resolve. + PolicyChains: map[string]*models.PolicyChain{}, + } + + err := pm.AddRuntimeConfig("Agent:assistant", bad) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid policy chain resolution") + + _, exists := store.Get("Agent:assistant") + assert.False(t, exists, "a config that fails validation must never be stored") +} + +// A well-formed RDC still goes through. +func TestAddRuntimeConfigAcceptsValidReferences(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + sm := NewSnapshotManager(logger) + pm := NewPolicyManager(sm, logger) + + store := newTestRuntimeStore() + sm.SetRuntimeStore(store) + pm.SetRuntimeStore(store) + + require.NoError(t, pm.AddRuntimeConfig("RestApi:petstore", restRDC())) + _, exists := store.Get("RestApi:petstore") + assert.True(t, exists) +} + +// The translator runs over configs read back from the store, including ones written +// before ValidateResolution rejected a nil chain, so it must not depend on that check +// having run: a panic here would take down snapshot generation for every API at once, +// not just the malformed one. +func TestTranslateSkipsNilChainsAndRoutesWithoutPanicking(t *testing.T) { + rdc := &models.RuntimeDeployConfig{ + Metadata: models.Metadata{UUID: "api-1", Kind: "RestApi", Handle: "petstore"}, + PolicyChainResolver: models.RouteKeyResolverName, + Routes: map[string]*models.Route{ + "GET|/pets|h": {Method: "GET", Path: "/pets", Vhost: "h"}, + "GET|/nil|h": nil, + }, + PolicyChains: map[string]*models.PolicyChain{ + "GET|/pets|h": {}, + "GET|/nil|h": nil, + }, + UpstreamClusters: map[string]*models.UpstreamCluster{ + "upstream_main": {BasePath: "/", Endpoints: []models.Endpoint{{Host: "localhost", Port: 8080}}}, + }, + } + + resources, err := testTranslator().TranslateRuntimeConfigs([]*models.RuntimeDeployConfig{rdc}) + require.NoError(t, err) + + // The well-formed siblings still publish — one bad entry must not cost the rest. + assert.Contains(t, resources[PolicyChainTypeURL], "GET|/pets|h") + assert.Contains(t, resources[RouteConfigTypeURL], "GET|/pets|h") + assert.NotContains(t, resources[PolicyChainTypeURL], "GET|/nil|h") + assert.NotContains(t, resources[RouteConfigTypeURL], "GET|/nil|h") +} diff --git a/gateway/gateway-controller/pkg/policyxds/snapshot.go b/gateway/gateway-controller/pkg/policyxds/snapshot.go index 6aa6e336d..ea9bccc30 100644 --- a/gateway/gateway-controller/pkg/policyxds/snapshot.go +++ b/gateway/gateway-controller/pkg/policyxds/snapshot.go @@ -245,6 +245,17 @@ func (t *Translator) TranslateRuntimeConfigs(rdcs []*models.RuntimeDeployConfig) for _, rdc := range rdcs { // Build policy chain resources (one per chain, including empty chains) for routeKey, chain := range rdc.PolicyChains { + // Skip-and-log rather than dereference. models.ValidateResolution rejects a nil + // chain at deploy time, but this translator also runs over configs read back + // from the store — including ones written before that check existed — and a + // panic here takes down snapshot generation for every API, not just this one. + if chain == nil { + t.logger.Error("Skipping nil policy chain", + slog.String("route_key", routeKey), + slog.String("kind", rdc.Metadata.Kind), + slog.String("name", rdc.Metadata.DisplayName)) + continue + } resource, err := t.createPolicyChainResource(routeKey, chain, rdc.Metadata, rdc.SensitiveValues) if err != nil { t.logger.Error("Failed to create policy chain resource", @@ -257,6 +268,16 @@ func (t *Translator) TranslateRuntimeConfigs(rdcs []*models.RuntimeDeployConfig) // Build route config resources (one per route) for routeKey, route := range rdc.Routes { + // Same reasoning as the nil chain above: route.Upstream is dereferenced on the + // next line, before createRouteConfigResource is even reached. + if route == nil { + t.logger.Error("Skipping nil route", + slog.String("route_key", routeKey), + slog.String("kind", rdc.Metadata.Kind), + slog.String("name", rdc.Metadata.DisplayName)) + continue + } + // Find upstream base path from the route's cluster upstreamBasePath := "/" if uc, ok := rdc.UpstreamClusters[route.Upstream.ClusterKey]; ok { @@ -368,13 +389,46 @@ func (t *Translator) createRouteConfigResource( } data := map[string]interface{}{ - "route_key": routeKey, - "metadata": metadataMap, - "resolver_name": rdc.PolicyChainResolver, + "route_key": routeKey, + "metadata": metadataMap, + // The effective resolver: the route's own override, or the RDC-level + // compatibility default. Every transformer shipping today leaves the route + // field empty, so this is byte-identical to what it emitted before per-route + // resolvers existed. + "resolver_name": rdc.EffectiveResolverName(route), "upstream_base_path": upstreamBasePath, "upstream_definition_paths": upstreamDefPaths, } + // Emitted explicitly on every *directly-resolved* route, including one where it + // equals the route key. The policy engine reads this field and never reconstructs + // the key, which is what keeps a later move of operation chains into their own key + // namespace a controller-only change. + // + // Omitted on a route naming a protocol resolver: that route derives its key from its + // own resolver_config, so a canonical key beside it would be a second copy of the + // same fact, and two copies can disagree with nothing to say which wins. + // ValidateResolution rejects such a route for carrying one, so emitting the route-key + // fallback here would put on the wire exactly what the model refuses to accept. + if models.IsDirectlyResolved(rdc.EffectiveResolverName(route)) { + data["canonical_chain_key"] = rdc.EffectiveCanonicalChainKey(routeKey, route) + } + + // Omitted when empty, deliberately. A field serialising as {} or 0 changes the + // resource's content, which re-versions it and pushes a RouteConfig update to every + // connected runtime for no behavioural reason. Only routes that actually need + // request-time resolution carry these. + if len(route.ResolverConfig) > 0 { + var decoded interface{} + if err := json.Unmarshal(route.ResolverConfig, &decoded); err != nil { + return nil, fmt.Errorf("route %q: resolver_config is not valid JSON: %w", routeKey, err) + } + data["resolver_config"] = decoded + } + if route.MaxRequestBodyBytes > 0 { + data["max_request_body_bytes"] = float64(route.MaxRequestBodyBytes) + } + // Add default upstream cluster info if route.Upstream.UseClusterHeader && route.Upstream.DefaultCluster != "" { data["default_upstream_cluster"] = route.Upstream.DefaultCluster diff --git a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go index 9978f14ae..3b833a328 100644 --- a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go +++ b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main.go @@ -44,6 +44,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/pkg/cel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/pythonbridge" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/tracing" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/utils" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/xdsclient" @@ -157,6 +158,14 @@ func main() { k := kernel.NewKernel() reg := registry.GetRegistry() + // Freeze the operation-resolver registry before anything reads it: no resolver + // can be registered once the kernel and the xDS client are running, so what the + // runtime advertises to the control plane and what it can actually serve are the + // same set for the whole process lifetime. + resolvers := resolver.DefaultRegistry() + slog.InfoContext(ctx, "Operation resolvers registered", + "resolvers", resolvers.Names(), "protocol_version", resolver.ProtocolVersion) + // Set config in registry for ${config} CEL resolution if err := reg.SetConfig(cfg.PolicyEngine.RawConfig); err != nil { slog.ErrorContext(ctx, "Failed to set config in registry", "error", err) @@ -196,7 +205,7 @@ func main() { slog.ErrorContext(ctx, "Error: -xds-server flag is required when config mode is 'xds'") os.Exit(1) } - xdsClient, err = initializeXDSClient(ctx, cfg, *xdsServerAddr, k, reg) + xdsClient, err = initializeXDSClient(ctx, cfg, *xdsServerAddr, k, reg, resolvers) if err != nil { slog.ErrorContext(ctx, "Failed to initialize xDS client", "error", err) os.Exit(1) @@ -254,7 +263,7 @@ func main() { slog.InfoContext(ctx, "Policy Engine listening on TCP port", "port", cfg.PolicyEngine.Server.ExtProcPort) } - grpcServer := grpc.NewServer() + grpcServer := grpc.NewServer(extProcServerOptions(cfg)...) extprocv3.RegisterExternalProcessorServer(grpcServer, extprocServer) // Enable block/mutex profiling sampling when pprof is enabled. These are the @@ -404,7 +413,7 @@ func setupLogger(cfg *config.Config) *slog.Logger { } // initializeXDSClient initializes and starts the xDS client -func initializeXDSClient(ctx context.Context, cfg *config.Config, serverAddr string, k *kernel.Kernel, reg *registry.PolicyRegistry) (*xdsclient.Client, error) { +func initializeXDSClient(ctx context.Context, cfg *config.Config, serverAddr string, k *kernel.Kernel, reg *registry.PolicyRegistry, resolvers resolver.ResolverRegistry) (*xdsclient.Client, error) { slog.InfoContext(ctx, "Initializing xDS client", "server", serverAddr) @@ -420,7 +429,7 @@ func initializeXDSClient(ctx context.Context, cfg *config.Config, serverAddr str TLSCAPath: cfg.PolicyEngine.XDS.TLS.CAPath, } - client, err := xdsclient.NewClient(xdsConfig, k, reg) + client, err := xdsclient.NewClient(xdsConfig, k, reg, resolvers) if err != nil { return nil, fmt.Errorf("failed to create xDS client: %w", err) } @@ -443,3 +452,23 @@ func initializeFileConfig(ctx context.Context, cfg *config.Config, k *kernel.Ker return nil } + +// extProcServerOptions bounds the ext_proc gRPC server explicitly, rather than taking +// gRPC's defaults: the receive default is 4 MiB whatever the body ceilings are configured +// to be, the send default is unbounded, and the concurrent-stream default is effectively +// unlimited. This is the hottest gRPC server in the data plane, so all three are set from +// validated configuration (see config.Config.Validate, which refuses to start when a +// message limit is below what the body ceilings require). +func extProcServerOptions(cfg *config.Config) []grpc.ServerOption { + server := cfg.PolicyEngine.Server + slog.Info("ext_proc gRPC server limits", + "max_recv_msg_bytes", server.MaxRecvMsgBytes, + "max_send_msg_bytes", server.MaxSendMsgBytes, + "max_concurrent_streams", server.MaxConcurrentStreams) + + return []grpc.ServerOption{ + grpc.MaxRecvMsgSize(int(server.MaxRecvMsgBytes)), + grpc.MaxSendMsgSize(int(server.MaxSendMsgBytes)), + grpc.MaxConcurrentStreams(server.MaxConcurrentStreams), + } +} diff --git a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main_test.go b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main_test.go index 1069701ac..25e206fe0 100644 --- a/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main_test.go +++ b/gateway/gateway-runtime/policy-engine/cmd/policy-engine/main_test.go @@ -28,10 +28,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/config" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" ) // ============================================================================= @@ -296,7 +298,7 @@ func TestInitializeXDSClient_InvalidConfig(t *testing.T) { }, } - _, err := initializeXDSClient(context.Background(), cfg, "", k, reg) + _, err := initializeXDSClient(context.Background(), cfg, "", k, reg, resolver.DefaultRegistry()) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to create xDS client") @@ -322,7 +324,7 @@ func TestInitializeXDSClient_ValidConfig(t *testing.T) { // Note: This will fail to actually connect since there's no server, // but the client creation and start attempt should work - client, err := initializeXDSClient(context.Background(), cfg, "localhost:18000", k, reg) + client, err := initializeXDSClient(context.Background(), cfg, "localhost:18000", k, reg, resolver.DefaultRegistry()) // Client should be created successfully even if it can't connect require.NoError(t, err) @@ -331,3 +333,23 @@ func TestInitializeXDSClient_ValidConfig(t *testing.T) { // Note: Not calling Stop/Wait due to potential issues with context in test environment // The client will be cleaned up when the test exits } + +// The ext_proc server must be constructed with all three bounds set. The values +// themselves are validated in internal/config; what this pins is that none of the three +// options is dropped from the construction, which is how this server silently ran on +// gRPC's defaults — a 4 MiB receive cap and unbounded streams — before. +func TestExtProcServerOptions(t *testing.T) { + cfg := &config.Config{} + cfg.PolicyEngine.Server.MaxRecvMsgBytes = 11 << 20 + cfg.PolicyEngine.Server.MaxSendMsgBytes = 11 << 20 + cfg.PolicyEngine.Server.MaxConcurrentStreams = 4096 + + opts := extProcServerOptions(cfg) + assert.Len(t, opts, 3, "MaxRecvMsgSize, MaxSendMsgSize and MaxConcurrentStreams") + + // And the real constructor accepts them, rather than this merely being a slice of + // the right length. + srv := grpc.NewServer(opts...) + require.NotNil(t, srv) + srv.Stop() +} diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go b/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go index 84c65bd48..d125dfcae 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/dumper.go @@ -21,6 +21,7 @@ package admin import ( "time" + "github.com/wso2/api-platform/common/chainkey" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" @@ -102,6 +103,13 @@ func dumpRouteMetadata(k *kernel.Kernel) RouteMetadataDump { UpstreamBasePath: cfg.Metadata.UpstreamBasePath, UpstreamDefinitionPaths: cfg.Metadata.UpstreamDefinitionPaths, DefaultUpstream: cfg.Metadata.DefaultUpstream, + + CanonicalChainKey: cfg.CanonicalChainKey, + ResolverName: cfg.ResolverName, + ChainKeyPrefix: resolverChainKeyPrefix(cfg), + MaxRequestBodyBytes: resolverBufferLimit(cfg), + ResolverStatic: cfg.Prepared.IsStatic(), + ResolverBuffersBody: resolverBuffersBody(cfg), }) } @@ -111,6 +119,39 @@ func dumpRouteMetadata(k *kernel.Kernel) RouteMetadataDump { } } +// resolverChainKeyPrefix returns the apiID/vhost prefix the engine composes this +// route's operation chain keys from, or "" for an identity route, which composes +// nothing. Built with the same shared helper as the keys themselves — passing an empty +// operation yields exactly the prefix — so the dump cannot drift from what is probed. +func resolverChainKeyPrefix(cfg *kernel.RouteConfig) string { + if cfg == nil || cfg.IsIdentity() { + return "" + } + return chainkey.For(cfg.Metadata.APIId, cfg.Metadata.Vhost, "") +} + +// resolverBuffersBody reports whether this route's resolver reads the request body, +// which is what defers chain selection — and every policy on it — to the request-body +// callback. +func resolverBuffersBody(cfg *kernel.RouteConfig) bool { + if cfg == nil || cfg.Prepared == nil { + return false + } + return cfg.Prepared.Requirements.BuffersBody() +} + +// resolverBufferLimit reports the wire-byte ceiling in force on a body-resolved +// route, resolving the default rather than reporting 0 — an operator reading the dump +// needs the bound that actually applies. Every other route reports nothing, since the +// limit only governs bodies buffered before the chain (and therefore authentication) +// is known. +func resolverBufferLimit(cfg *kernel.RouteConfig) int64 { + if !resolverBuffersBody(cfg) { + return 0 + } + return cfg.EffectiveMaxRequestBodyBytes() +} + // dumpPolicySpecs converts SDK PolicySpecs to admin PolicySpecs func dumpPolicySpecs(specs []policy.PolicySpec) []PolicySpec { result := make([]PolicySpec, 0, len(specs)) diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/dumper_test.go b/gateway/gateway-runtime/policy-engine/internal/admin/dumper_test.go index 26d246d03..38bd5eb44 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/dumper_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/dumper_test.go @@ -19,13 +19,17 @@ package admin import ( + "context" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wso2/api-platform/common/chainkey" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" ) @@ -345,3 +349,110 @@ func TestDumpLazyResources(t *testing.T) { // The lazy resource store is a singleton, so we just verify structure assert.NotNil(t, result.ResourcesByType) } + +// ============================================================================= +// Policy chain resolution in the route dump +// ============================================================================= + +// bodyResolver is a stand-in for a real multiplexed resolver: it reads the request body, +// so the routes it prepares are the ones the buffer limit and the deferred path govern. +// The shipped binary registers only route-key, so nothing like this is reachable in +// production yet. +type bodyResolver struct{ name string } + +func (r *bodyResolver) Name() string { return r.name } + +func (r *bodyResolver) Prepare(resolver.ResolverRouteConfig) (resolver.PreparedResolver, error) { + return &preparedBodyResolver{}, nil +} + +type preparedBodyResolver struct{} + +func (*preparedBodyResolver) Requirements() resolver.RequestRequirements { + return resolver.RequestRequirements{Body: resolver.BodyBuffered} +} + +func (*preparedBodyResolver) Resolve(context.Context, resolver.RequestView) (resolver.Resolution, error) { + return resolver.Resolution{}, nil +} + +// prepareRoute prepares rc the way xDS ingest does, so the dump reads the same prepared +// state a running engine would. +func prepareRoute(t *testing.T, routeKey string, rc *kernel.RouteConfig, resolvers ...resolver.Resolver) *kernel.RouteConfig { + t.Helper() + reg := resolver.NewRegistry() + require.NoError(t, reg.Register(&resolver.RouteKeyResolver{})) + for _, r := range resolvers { + require.NoError(t, reg.Register(r)) + } + reg.Freeze() + require.NoError(t, kernel.PrepareRoute(reg, routeKey, rc)) + return rc +} + +// An identity route — every kind shipping today — echoes its chain key and reports +// nothing else, so the dump is effectively unchanged for existing deployments. +func TestDumpRouteMetadata_IdentityRouteReportsChainKeyOnly(t *testing.T) { + k := kernel.NewKernel() + k.ApplyWholeRouteConfigs(map[string]*kernel.RouteConfig{ + "GET|/pets|localhost": prepareRoute(t, "GET|/pets|localhost", &kernel.RouteConfig{ + Metadata: kernel.RouteMetadata{APIName: "PetStore"}, + RouteResolution: resolver.RouteResolution{ + CanonicalChainKey: "GET|/pets|localhost", + ResolverName: resolver.RouteKeyResolverName, + }, + }), + }) + + entry := dumpRouteMetadata(k).Routes[0] + assert.Equal(t, "GET|/pets|localhost", entry.CanonicalChainKey) + assert.Equal(t, resolver.RouteKeyResolverName, entry.ResolverName) + assert.Empty(t, entry.ChainKeyPrefix, "an identity route composes nothing") + assert.Zero(t, entry.MaxRequestBodyBytes, + "the buffer limit only governs bodies buffered before the chain is known, which identity routes never do") + assert.True(t, entry.ResolverStatic, "an identity route resolves entirely at ingest") + assert.False(t, entry.ResolverBuffersBody) +} + +// On a multiplexed route the dump is the only way to answer "why did this request get +// that chain?" from outside the process. Under composed keys there is no per-route +// mapping to show, so what an operator needs is the prefix the engine joins a resolved +// operation onto — enough to match a dumped chain key back to the route that reaches it. +func TestDumpRouteMetadata_MultiplexedRouteReportsChainKeyPrefix(t *testing.T) { + k := kernel.NewKernel() + k.ApplyWholeRouteConfigs(map[string]*kernel.RouteConfig{ + "POST|/rpc|localhost": prepareRoute(t, "POST|/rpc|localhost", &kernel.RouteConfig{ + Metadata: kernel.RouteMetadata{APIName: "Assistant", APIId: "agent-1", Vhost: "localhost"}, + RouteResolution: resolver.RouteResolution{ + ResolverName: "fake-multiplexed", + }, + }, &bodyResolver{name: "fake-multiplexed"}), + }) + + entry := dumpRouteMetadata(k).Routes[0] + assert.Equal(t, "fake-multiplexed", entry.ResolverName) + assert.False(t, entry.ResolverStatic, "a multiplexed route resolves per request") + assert.True(t, entry.ResolverBuffersBody) + + // Built from the same helper as the keys themselves, so the dump cannot drift from + // what is actually probed: a composed key for any operation starts with this. + assert.Equal(t, chainkey.For("agent-1", "localhost", ""), entry.ChainKeyPrefix) + assert.True(t, strings.HasPrefix( + chainkey.For("agent-1", "localhost", "SendMessage"), entry.ChainKeyPrefix)) + + // The default is resolved rather than reported as 0: an operator needs the bound + // that actually applies, not the raw configured value. + assert.Equal(t, kernel.DefaultMaxResolverRequestBodyBytes, entry.MaxRequestBodyBytes) +} + +func TestDumpRouteMetadata_ExplicitBufferLimitIsReported(t *testing.T) { + k := kernel.NewKernel() + k.ApplyWholeRouteConfigs(map[string]*kernel.RouteConfig{ + "POST|/rpc|localhost": prepareRoute(t, "POST|/rpc|localhost", &kernel.RouteConfig{ + RouteResolution: resolver.RouteResolution{ResolverName: "fake-multiplexed"}, + MaxRequestBodyBytes: 4096, + }, &bodyResolver{name: "fake-multiplexed"}), + }) + + assert.Equal(t, int64(4096), dumpRouteMetadata(k).Routes[0].MaxRequestBodyBytes) +} diff --git a/gateway/gateway-runtime/policy-engine/internal/admin/types.go b/gateway/gateway-runtime/policy-engine/internal/admin/types.go index b643c1f8b..253b03923 100644 --- a/gateway/gateway-runtime/policy-engine/internal/admin/types.go +++ b/gateway/gateway-runtime/policy-engine/internal/admin/types.go @@ -119,6 +119,39 @@ type RouteMetadataEntry struct { // DefaultUpstream is this route's own compiled-in upstream (whichever slot it // belongs to). DefaultUpstream *policyenginev1.UpstreamInfo `json:"default_upstream,omitempty"` + + // ─── Policy chain resolution ───────────────────────────────────────────── + // + // Which chain a request on this route actually gets. On an identity route + // CanonicalChainKey equals RouteKey and the rest is empty, so the dump for every + // kind shipping today is unchanged apart from that one echoed value. + // + // On a multiplexed route these are the only way to answer "why did this request + // get that chain?" from outside the process: the resolver names what reads the + // operation out of the request, and ChainKeyPrefix is what the engine joins that + // operation onto. + CanonicalChainKey string `json:"canonical_chain_key"` + // ResolverName is empty for an identity route. + ResolverName string `json:"resolver_name,omitempty"` + // ChainKeyPrefix is the composed-key prefix for this route: the apiID and vhost + // the engine will join a resolved operation onto. Absent on identity routes, which + // have no operation to compose. It replaces the old operation_map dump — under + // composed keys there is no per-route mapping to show, so what an operator needs + // instead is the prefix to match a dumped chain key against. + ChainKeyPrefix string `json:"chain_key_prefix,omitempty"` + // MaxRequestBodyBytes is the effective acceptance ceiling on a body-resolved route, + // reported even when it came from the default so an operator can see the bound that + // is actually in force rather than having to infer it. It caps unauthenticated + // decompression and parsing work, not how much Envoy buffers. + MaxRequestBodyBytes int64 `json:"max_request_body_bytes,omitempty"` + // ResolverStatic reports that this route's resolution was fully determined at + // ingest, so no resolver runs per request. True for every route of every kind + // shipping today; false means the route inspects each request to pick its chain. + ResolverStatic bool `json:"resolver_static,omitempty"` + // ResolverBuffersBody reports that this route's resolver reads the request body, + // which defers chain selection — and therefore every policy, including + // authentication — to the request-body callback. + ResolverBuffersBody bool `json:"resolver_buffers_body,omitempty"` } // PolicySpec contains specification for a policy instance diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config.go b/gateway/gateway-runtime/policy-engine/internal/config/config.go index 7bb39ead3..ce24d4f00 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config.go @@ -40,6 +40,33 @@ const ( // from a Content-Encoded body — the whole body when buffered, each chunk when // streaming. Applied when max_decompressed_bytes is unset for a direction. DefaultMaxDecompressedBytes int64 = 10 * 1024 * 1024 // 10 MiB + + // ExtProcMessageOverheadBytes is the headroom an ext_proc message needs above the + // body it carries: request/response headers, Envoy attributes, dynamic metadata and + // protobuf framing all travel in the same message. The gRPC message limits are + // validated against the body ceilings plus this, because a limit sized to the body + // alone fails mid-request with ResourceExhausted on any request whose headers are + // large — a failure that looks like a gateway fault rather than a misconfiguration. + ExtProcMessageOverheadBytes int64 = 1 * 1024 * 1024 // 1 MiB + + // maxConfigurableDecompressedBytes is the largest body ceiling that can still have + // ExtProcMessageOverheadBytes added to it without overflowing int64. Validate rejects + // anything above it, which is what lets RequiredExtProcMessageBytes add without + // checking. Far beyond any real deployment — the point is that the arithmetic is + // total, not that the number is reachable. + maxConfigurableDecompressedBytes int64 = math.MaxInt64 - ExtProcMessageOverheadBytes + + // DefaultMaxConcurrentStreams bounds in-flight ext_proc calls on the Envoy + // connection, one stream per request being processed. gRPC's own default is + // effectively unlimited, so an explicit value is what makes the stream budget a + // bounded resource rather than whatever the peer asks for. + // + // Deliberately generous. This is not a load-shedding control: Envoy does not + // degrade gracefully when it runs out of streams, it stalls, so a value below a + // pod's peak concurrent in-flight requests costs availability rather than + // protecting anything. Raise it if a single runtime instance legitimately carries + // more concurrency than this. + DefaultMaxConcurrentStreams uint32 = 10000 ) // defaultFileSourceAllowlist is the policy-engine's default set of directories that @@ -239,6 +266,40 @@ type ServerConfig struct { // ExtProcPort is the port for the ext_proc gRPC server (TCP mode only) ExtProcPort int `koanf:"extproc_port"` + + // MaxRecvMsgBytes and MaxSendMsgBytes bound one ext_proc message in each + // direction. Both must accommodate the larger of the two body decompression + // ceilings plus ExtProcMessageOverheadBytes, because both directions carry both + // kinds of body: the engine receives a request body and may return a mutated one, + // then receives a response body and may return a mutated one. + // + // They exist because gRPC's defaults are not this service's threat model — the + // receive default is 4 MiB regardless of how the body ceilings are configured, and + // the send default is unbounded. + MaxRecvMsgBytes int64 `koanf:"max_recv_msg_bytes"` + MaxSendMsgBytes int64 `koanf:"max_send_msg_bytes"` + + // MaxConcurrentStreams bounds concurrent in-flight ext_proc calls. See + // DefaultMaxConcurrentStreams for why this is a generous bound rather than a + // load-shedding knob. + MaxConcurrentStreams uint32 `koanf:"max_concurrent_streams"` +} + +// RequiredExtProcMessageBytes is the smallest message limit coherent with the +// configured body ceilings. Both directions are sized off the larger ceiling, since +// each carries request and response bodies alike. +// +// The addition cannot overflow: Validate rejects a ceiling above +// maxConfigurableDecompressedBytes before reaching here. That ordering matters — an +// overflowed sum would be *negative*, and a negative requirement compares below every +// configured message limit, so the coherence checks that follow would pass an absurd +// ceiling instead of refusing it. +func (p PolicyEngine) RequiredExtProcMessageBytes() int64 { + ceiling := p.RequestBody.MaxDecompressedBytes + if p.ResponseBody.MaxDecompressedBytes > ceiling { + ceiling = p.ResponseBody.MaxDecompressedBytes + } + return ceiling + ExtProcMessageOverheadBytes } // PythonExecutorConfig holds configuration for the Python executor bridge. @@ -513,6 +574,13 @@ func defaultConfig() *Config { Server: ServerConfig{ Mode: "", ExtProcPort: 9001, + // MaxRecvMsgBytes and MaxSendMsgBytes are deliberately left zero: Validate + // derives them from the effective body ceilings, and a default here would + // pre-empt that derivation. Since Load starts from this config, a non-zero + // default is indistinguishable from an operator's explicit choice — so + // raising request_body.max_decompressed_bytes would fail startup demanding + // the message limits be restated, instead of following the ceiling up. + MaxConcurrentStreams: DefaultMaxConcurrentStreams, }, Admin: AdminConfig{ Enabled: true, @@ -682,6 +750,70 @@ func (c *Config) Validate() error { return fmt.Errorf("policy_engine.response_body.max_decompressed_bytes must be positive, got %d", c.PolicyEngine.ResponseBody.MaxDecompressedBytes) } + // Both ceilings feed RequiredExtProcMessageBytes, which adds + // ExtProcMessageOverheadBytes to the larger of them. Bounding them here, before that + // addition happens, is what keeps it total. + for name, v := range map[string]int64{ + "request_body": c.PolicyEngine.RequestBody.MaxDecompressedBytes, + "response_body": c.PolicyEngine.ResponseBody.MaxDecompressedBytes, + } { + if v > maxConfigurableDecompressedBytes { + return fmt.Errorf( + "policy_engine.%s.max_decompressed_bytes is %d, which exceeds the maximum %d — "+ + "a larger ceiling cannot have the %d of ext_proc message overhead added to it "+ + "without overflowing", + name, v, maxConfigurableDecompressedBytes, ExtProcMessageOverheadBytes) + } + } + + // ext_proc gRPC message and stream limits. + // + // Unset means "derive from the body ceilings" rather than "reject", so a Config built + // in code (tests, embedders) stays usable and an operator who raises a body ceiling + // does not also have to restate the message limits. Load() starts from + // defaultConfig(), so a file-sourced config already carries values; this covers the + // rest. Same normalise-then-validate shape as the router's + // per_connection_buffer_limit_bytes. + required := c.PolicyEngine.RequiredExtProcMessageBytes() + if c.PolicyEngine.Server.MaxRecvMsgBytes == 0 { + c.PolicyEngine.Server.MaxRecvMsgBytes = required + } + if c.PolicyEngine.Server.MaxSendMsgBytes == 0 { + c.PolicyEngine.Server.MaxSendMsgBytes = required + } + if c.PolicyEngine.Server.MaxConcurrentStreams == 0 { + c.PolicyEngine.Server.MaxConcurrentStreams = DefaultMaxConcurrentStreams + } + + // An *explicit* value below the ceiling is still refused: a message limit under the + // body a policy is allowed to buffer fails mid-request with ResourceExhausted, which + // surfaces as a gateway fault on live traffic instead of a startup error naming the + // two settings that disagree. Refusing to start is the cheaper failure. + if c.PolicyEngine.Server.MaxRecvMsgBytes < required { + return fmt.Errorf( + "policy_engine.server.max_recv_msg_bytes is %d, which is below the %d required by the configured "+ + "body decompression ceilings plus %d of ext_proc message overhead", + c.PolicyEngine.Server.MaxRecvMsgBytes, required, ExtProcMessageOverheadBytes) + } + if c.PolicyEngine.Server.MaxSendMsgBytes < required { + return fmt.Errorf( + "policy_engine.server.max_send_msg_bytes is %d, which is below the %d required by the configured "+ + "body decompression ceilings plus %d of ext_proc message overhead", + c.PolicyEngine.Server.MaxSendMsgBytes, required, ExtProcMessageOverheadBytes) + } + // grpc.MaxRecvMsgSize/MaxSendMsgSize take an int, so a value that does not survive + // the conversion would silently become a different limit than the one configured. + // int is 64-bit on every platform this ships on, making this unreachable there — + // which is the point of asserting it here rather than at the conversion. + for name, v := range map[string]int64{ + "max_recv_msg_bytes": c.PolicyEngine.Server.MaxRecvMsgBytes, + "max_send_msg_bytes": c.PolicyEngine.Server.MaxSendMsgBytes, + } { + if int64(int(v)) != v { + return fmt.Errorf("policy_engine.server.%s is %d, which does not fit this platform's int", name, v) + } + } + // Validate config mode if c.PolicyEngine.ConfigMode.Mode != "file" && c.PolicyEngine.ConfigMode.Mode != "xds" { return fmt.Errorf("invalid config_mode.mode: %s (must be 'file' or 'xds')", c.PolicyEngine.ConfigMode.Mode) diff --git a/gateway/gateway-runtime/policy-engine/internal/config/config_test.go b/gateway/gateway-runtime/policy-engine/internal/config/config_test.go index c9e9d51a4..49eccc433 100644 --- a/gateway/gateway-runtime/policy-engine/internal/config/config_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/config/config_test.go @@ -138,6 +138,64 @@ func TestValidate_MaxDecompressedBytes(t *testing.T) { } } +// A body ceiling is bounded above as well as below, because +// RequiredExtProcMessageBytes adds ExtProcMessageOverheadBytes to the larger of the two. +// Without the bound that addition wraps, and the wrap is worse than a wrong number: the sum +// goes *negative*, a negative requirement compares below every configured message limit, and +// the coherence checks downstream would accept the absurd ceiling instead of refusing it. +func TestValidate_MaxDecompressedBytesOverflowBound(t *testing.T) { + directions := []struct { + name string + set func(cfg *Config, v int64) + }{ + {name: "request", set: func(cfg *Config, v int64) { cfg.PolicyEngine.RequestBody.MaxDecompressedBytes = v }}, + {name: "response", set: func(cfg *Config, v int64) { cfg.PolicyEngine.ResponseBody.MaxDecompressedBytes = v }}, + } + + for _, dir := range directions { + // Exactly at the bound: accepted, and the sum is the largest int64 rather than a + // wrapped negative. The message limits must be raised to match, since the + // coherence check refuses a limit below the requirement. + t.Run(dir.name+"/at the bound", func(t *testing.T) { + cfg := validConfig() + dir.set(cfg, maxConfigurableDecompressedBytes) + cfg.PolicyEngine.Server.MaxRecvMsgBytes = math.MaxInt64 + cfg.PolicyEngine.Server.MaxSendMsgBytes = math.MaxInt64 + + require.NoError(t, cfg.Validate()) + assert.Equal(t, int64(math.MaxInt64), cfg.PolicyEngine.RequiredExtProcMessageBytes(), + "the boundary value must sum to MaxInt64, not wrap") + assert.Positive(t, cfg.PolicyEngine.RequiredExtProcMessageBytes()) + }) + + // One byte over: rejected at startup, naming the setting and the bound. + t.Run(dir.name+"/one over the bound", func(t *testing.T) { + cfg := validConfig() + dir.set(cfg, maxConfigurableDecompressedBytes+1) + + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "max_decompressed_bytes") + assert.Contains(t, err.Error(), "without overflowing") + }) + + // The extreme case, and the one that would wrap furthest. + t.Run(dir.name+"/max int64", func(t *testing.T) { + cfg := validConfig() + dir.set(cfg, math.MaxInt64) + + require.Error(t, cfg.Validate(), "MaxInt64 leaves no room for the message overhead") + }) + } +} + +// The bound is derived from the overhead it must accommodate rather than restated, so the +// two cannot drift apart. +func TestMaxConfigurableDecompressedBytes_LeavesRoomForTheOverhead(t *testing.T) { + assert.Equal(t, int64(math.MaxInt64), + maxConfigurableDecompressedBytes+ExtProcMessageOverheadBytes) +} + // TestDefaultConfig_MaxDecompressedBytes verifies the default is applied to both // directions so the decompression guard is active out of the box. func TestDefaultConfig_MaxDecompressedBytes(t *testing.T) { @@ -1502,6 +1560,73 @@ format = "json" // TestLoad_TokenResolvesFromEnv verifies the {{ env }} interpolation path: a config // value written as a token is resolved from the environment at load time. +// Raising a body ceiling must carry the ext_proc message limits up with it. An operator +// who raises request_body.max_decompressed_bytes and says nothing about the message limits +// should get a working gateway, not a startup error demanding they restate two more +// settings — the failure mode a non-zero default in defaultConfig produced, since Load +// starts from that config and a default is indistinguishable from a stated value. +func TestLoad_RaisedBodyCeilingDerivesMessageLimits(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + + const raised = 50 * 1024 * 1024 + // Deliberately no [policy_engine.server] max_recv_msg_bytes / max_send_msg_bytes. + configContent := ` +[policy_engine.config_mode] +mode = "file" + +[policy_engine.file_config] +path = "/tmp/policies.yaml" + +[policy_engine.request_body] +max_decompressed_bytes = 52428800 +` + require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) + + cfg, err := Load(configPath) + require.NoError(t, err, "a raised ceiling with no message limits stated must load") + + assert.Equal(t, int64(raised), cfg.PolicyEngine.RequestBody.MaxDecompressedBytes) + + want := int64(raised) + ExtProcMessageOverheadBytes + assert.Equal(t, want, cfg.PolicyEngine.Server.MaxRecvMsgBytes, + "the receive limit must follow the raised ceiling") + assert.Equal(t, want, cfg.PolicyEngine.Server.MaxSendMsgBytes, + "and so must the send limit — both directions carry both bodies") + + // The response ceiling was left at its default, so it must not have dragged the + // requirement back down: the requirement comes from the *larger* of the two. + assert.Equal(t, DefaultMaxDecompressedBytes, cfg.PolicyEngine.ResponseBody.MaxDecompressedBytes) + assert.Equal(t, want, cfg.PolicyEngine.RequiredExtProcMessageBytes()) +} + +// An explicitly configured message limit is preserved, never overwritten by the +// derivation — the derivation fills a gap, it does not take the setting over. +func TestLoad_ExplicitMessageLimitsArePreserved(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + + // Both stated, and comfortably above the default ceiling's requirement. + configContent := ` +[policy_engine.config_mode] +mode = "file" + +[policy_engine.file_config] +path = "/tmp/policies.yaml" + +[policy_engine.server] +max_recv_msg_bytes = 99000000 +max_send_msg_bytes = 88000000 +` + require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) + + cfg, err := Load(configPath) + require.NoError(t, err) + + assert.Equal(t, int64(99000000), cfg.PolicyEngine.Server.MaxRecvMsgBytes) + assert.Equal(t, int64(88000000), cfg.PolicyEngine.Server.MaxSendMsgBytes) +} + func TestLoad_TokenResolvesFromEnv(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.toml") @@ -1654,3 +1779,86 @@ func TestDefaultConfig(t *testing.T) { err := cfg.Validate() assert.NoError(t, err) } + +// The ext_proc gRPC server carries every request and response body, so its message +// limits have to be coherent with the body ceilings. Incoherent config must stop the +// process at startup rather than fail mid-request with ResourceExhausted, which on live +// traffic looks like a gateway fault instead of a setting that needs changing. +func TestValidate_ExtProcMessageLimits(t *testing.T) { + baseline := func() *Config { + c := defaultConfig() + c.PolicyEngine.Server.Mode = "uds" + return c + } + + t.Run("defaults are coherent with the default ceilings", func(t *testing.T) { + c := baseline() + // defaultConfig leaves both message limits zero on purpose, so this is the + // derivation running — not a default that happens to agree with the ceiling. + require.Zero(t, c.PolicyEngine.Server.MaxRecvMsgBytes) + require.Zero(t, c.PolicyEngine.Server.MaxSendMsgBytes) + + require.NoError(t, c.Validate()) + want := DefaultMaxDecompressedBytes + ExtProcMessageOverheadBytes + assert.Equal(t, want, c.PolicyEngine.Server.MaxRecvMsgBytes) + assert.Equal(t, want, c.PolicyEngine.Server.MaxSendMsgBytes) + assert.Equal(t, DefaultMaxConcurrentStreams, c.PolicyEngine.Server.MaxConcurrentStreams) + }) + + // Unset derives rather than rejects, so a Config built in code stays usable and an + // operator who raises a body ceiling need not restate the message limits. + t.Run("unset limits are derived from the ceilings", func(t *testing.T) { + c := baseline() + c.PolicyEngine.RequestBody.MaxDecompressedBytes = 50 * 1024 * 1024 + c.PolicyEngine.Server.MaxRecvMsgBytes = 0 + c.PolicyEngine.Server.MaxSendMsgBytes = 0 + c.PolicyEngine.Server.MaxConcurrentStreams = 0 + + require.NoError(t, c.Validate()) + want := int64(50*1024*1024) + ExtProcMessageOverheadBytes + assert.Equal(t, want, c.PolicyEngine.Server.MaxRecvMsgBytes) + assert.Equal(t, want, c.PolicyEngine.Server.MaxSendMsgBytes) + assert.Equal(t, DefaultMaxConcurrentStreams, c.PolicyEngine.Server.MaxConcurrentStreams) + }) + + // Both directions are sized off the *larger* ceiling, because each carries request + // and response bodies alike: the engine receives a request body and may return a + // mutated one, then receives a response body and may return a mutated one. + t.Run("the larger ceiling sets the requirement for both directions", func(t *testing.T) { + c := baseline() + c.PolicyEngine.RequestBody.MaxDecompressedBytes = 2 * 1024 * 1024 + c.PolicyEngine.ResponseBody.MaxDecompressedBytes = 40 * 1024 * 1024 + + assert.Equal(t, int64(40*1024*1024)+ExtProcMessageOverheadBytes, + c.PolicyEngine.RequiredExtProcMessageBytes()) + }) + + for name, mutate := range map[string]func(*Config){ + "recv below the ceiling": func(c *Config) { + c.PolicyEngine.Server.MaxRecvMsgBytes = DefaultMaxDecompressedBytes + }, + "send below the ceiling": func(c *Config) { + c.PolicyEngine.Server.MaxSendMsgBytes = DefaultMaxDecompressedBytes + }, + // An explicit limit is honoured, not raised to fit: an operator who states one + // below the ceiling has two settings that disagree and must be told which. + "explicit recv below a raised ceiling": func(c *Config) { + c.PolicyEngine.ResponseBody.MaxDecompressedBytes = 100 * 1024 * 1024 + c.PolicyEngine.Server.MaxRecvMsgBytes = DefaultMaxDecompressedBytes + ExtProcMessageOverheadBytes + }, + "explicit send below a raised ceiling": func(c *Config) { + c.PolicyEngine.ResponseBody.MaxDecompressedBytes = 100 * 1024 * 1024 + c.PolicyEngine.Server.MaxSendMsgBytes = DefaultMaxDecompressedBytes + ExtProcMessageOverheadBytes + }, + } { + t.Run("rejected: "+name, func(t *testing.T) { + c := baseline() + mutate(c) + err := c.Validate() + require.Error(t, err) + // The error has to name the two settings that disagree, or the operator is + // left guessing which of them to change. + assert.Regexp(t, `max_(recv|send)_msg_bytes is \d+, which is below the \d+ required`, err.Error()) + }) + } +} diff --git a/gateway/gateway-runtime/policy-engine/internal/constants/constants.go b/gateway/gateway-runtime/policy-engine/internal/constants/constants.go index 9d893ab9c..95a1d3497 100644 --- a/gateway/gateway-runtime/policy-engine/internal/constants/constants.go +++ b/gateway/gateway-runtime/policy-engine/internal/constants/constants.go @@ -62,6 +62,15 @@ const ( // Cluster identifies the cluster this policy engine belongs to XDSCluster = "policy-engine-cluster" + // NodeMetaResolutionProtocolVersion is the Node.Metadata key carrying the + // operation-resolution protocol version this runtime implements. The control + // plane reads it to decide whether this runtime can be sent routes that need + // request-time operation resolution. + NodeMetaResolutionProtocolVersion = "resolution_protocol_version" + // NodeMetaSupportedResolvers is the Node.Metadata key carrying the sorted list + // of operation resolvers registered in this runtime. + NodeMetaSupportedResolvers = "supported_resolvers" + // Tracing Span Names SpanExternalProcessingProcess = "external_processing.process" SpanProcessRequestHeaders = "external_processing.process_request_headers" @@ -88,6 +97,9 @@ const ( AttrSkipReasonConditionNotMet = "condition_not_met" AttrPolicyExecutionTimeNS = "policy.execution_time_ns" AttrPolicyShortCircuit = "policy.short_circuit" + AttrResolverName = "resolver.name" + AttrPolicyChainKey = "policy_chain_key" + AttrResolvedOperation = "resolver.operation" // Terminal-outcome attributes. The status code itself is recorded under the // OTel semantic-convention key http.response.status_code by @@ -106,6 +118,11 @@ const ( TerminalReasonUnknownMessageType = "unknown_message_type" // unrecognised ext_proc message TerminalReasonProcessingFailed = "processing_failed" // a phase returned a fatal (stream-ending) error with no ImmediateResponse to classify + // TerminalReasonResolutionFailed marks a request whose logical operation could not + // be resolved to a policy chain. It exists because the status alone cannot identify + // one — an unknown-operation failure is an HTTP 404 just like an Envoy route miss. + TerminalReasonResolutionFailed = "resolution_failed" + // Analytics metadata and property keys shared across packages. GuardrailHitMetadataKey = "isGuardrailHit" GuardrailNameMetadataKey = "guardrailName" diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/body_mode.go b/gateway/gateway-runtime/policy-engine/internal/kernel/body_mode.go index 3abccd724..380752801 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/body_mode.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/body_mode.go @@ -184,7 +184,24 @@ func determineResponseBodyMode(chain *registry.PolicyChain) BodyMode { return BodyModeBuffered } -// GetRequestBodyMode returns the body mode for request phase +// GetRequestBodyMode and GetResponseBodyMode below are NOT the source of the +// processing modes sent to Envoy. +// +// The ModeOverride on the wire is built entirely by +// PolicyExecutionContext.getModeOverride in execution_context.go, which is the only +// place that knows the per-request state the decision depends on: whether the client +// actually sent a streaming body, whether the upstream response is streaming, +// whether the response has a body at all, and — for a route whose chain is selected +// at the request-body callback — whether a chain exists yet. These two functions see +// only a chain looked up by key, so they cannot answer any of that. +// +// They are retained because they are a useful pure summary of a chain's declared +// body requirements, and are exercised as such by body_mode_test.go. Do not "fix" +// them to account for deferred/pending routes: nothing reads their result, so a +// change here alters no behaviour. Change getModeOverride instead. + +// GetRequestBodyMode returns the body mode a chain's policies declare for the +// request phase. Not the mode source — see the note above. func (k *Kernel) GetRequestBodyMode(routeKey string) BodyMode { chain := k.GetPolicyChainForKey(routeKey) if chain == nil { @@ -193,7 +210,8 @@ func (k *Kernel) GetRequestBodyMode(routeKey string) BodyMode { return determineRequestBodyMode(chain) } -// GetResponseBodyMode returns the body mode for response phase +// GetResponseBodyMode returns the body mode a chain's policies declare for the +// response phase. Not the mode source — see the note above GetRequestBodyMode. func (k *Kernel) GetResponseBodyMode(routeKey string) BodyMode { chain := k.GetPolicyChainForKey(routeKey) if chain == nil { diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go b/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go index fc7e1a18b..4a9d01249 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/execution_context.go @@ -34,6 +34,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/tracing" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" @@ -165,6 +166,37 @@ type PolicyExecutionContext struct { // distinguish a policy-chosen status from a genuine upstream pass-through // (constants.TerminalReasonPolicyStatusOverride vs TerminalReasonUpstream). responseStatusOverridden bool + + // ─── Operation resolution ──────────────────────────────────────────────── + // All of the following are zero for every route that resolves its chain by + // identity, which is every API kind shipping today. + + // pending is non-nil between the request-headers and request-body callbacks of + // a route whose resolver must read the body. While it is set, policyChain is + // nil and no policy has run. + pending *pendingResolution + + // resolutionDenied records that this request's operation never resolved to a + // chain, so the response phases must not try to execute one. + resolutionDenied bool + + // boundAtBodyPhase records that the chain was selected during the request-body + // callback. Two consequences: header mutations are emitted on the body-phase + // response, and no ModeOverride is returned from it (Envoy applies one only on + // responses to header callbacks). + boundAtBodyPhase bool + + // resolverName is the effective resolver for this route, for logs, metrics and + // spans. Empty means identity. + resolverName string + + // chainKey is the policy chain key actually selected. Equals routeKey on an + // identity route. + chainKey string + + // operation is the canonical protocol operation the caller invoked. Empty for a + // directly-resolved route, whose chain key is already the route name on the span. + operation string } // generatedResponse ties a policy-engine-generated ImmediateResponse to the span @@ -440,6 +472,27 @@ func (ec *PolicyExecutionContext) requestPayloadTooLargeError( // ModeOverride sent to Envoy could disagree with which body-phase handler actually runs // (see processResponseBody), which Envoy rejects as a content-length/body mismatch. func (ec *PolicyExecutionContext) getModeOverride() *extprocconfigv3.ProcessingMode { + // No chain yet: the mode comes from the prepared resolver's requirements instead, + // since there is nothing else to derive it from. + if ec.pending != nil { + return pendingModeOverride(ec.pending.prepared.Requirements) + } + + // A chain bound at the request-body callback must not return a ModeOverride from + // that response: Envoy applies one only on responses to header callbacks and + // ignores it on body and trailer callbacks. Returning nil here (rather than a + // mode Envoy will discard) keeps the wire honest about where the response modes + // are actually decided — the response-header callback below. + if ec.boundAtBodyPhase && ec.phase == phaseRequestBody { + return nil + } + + if ec.policyChain == nil { + // Resolution denied, or a callback arrived for a request that never bound a + // chain. Ask Envoy for nothing further rather than dereferencing nil. + return pendingModeOverride(resolver.RequestRequirements{}) + } + mode := &extprocconfigv3.ProcessingMode{ ResponseHeaderMode: extprocconfigv3.ProcessingMode_SEND, } @@ -651,6 +704,16 @@ func (ec *PolicyExecutionContext) processRequestBody( body *extprocv3.HttpBody, ) (*extprocv3.ProcessingResponse, error) { ec.phase = phaseRequestBody + + // A route whose resolver reads the body selects its chain here, then runs that + // chain's request-header policies followed by its request-body policies. + if ec.pending != nil { + return ec.bindPendingChainAndProcess(ctx, body) + } + if ec.policyChain == nil { + return ec.noChainPassThroughRequestBody(), nil + } + if ec.isStreamingRequest { return ec.processStreamingRequestBody(ctx, body) } @@ -882,6 +945,13 @@ func (ec *PolicyExecutionContext) processResponseHeaders( headers *extprocv3.HttpHeaders, ) (*extprocv3.ProcessingResponse, error) { ec.phase = phaseResponseHeaders + if ec.policyChain == nil { + // Either resolution was denied (in which case Envoy already received an + // ImmediateResponse and should not be sending us response phases at all), or + // a body callback that would have bound the chain never arrived. Pass the + // response through untouched rather than dereferencing a nil chain. + return ec.noChainPassThroughResponseHeaders(), nil + } ec.buildResponseContexts(headers) // Detect streaming response: upgrade when chain supports streaming AND @@ -937,6 +1007,9 @@ func (ec *PolicyExecutionContext) processResponseBody( body *extprocv3.HttpBody, ) (*extprocv3.ProcessingResponse, error) { ec.phase = phaseResponseBody + if ec.policyChain == nil { + return ec.noChainPassThroughResponseBody(), nil + } if ec.isStreamingResponse { slog.Debug("[body] routing to streaming response body handler", "route", ec.routeKey, @@ -1317,7 +1390,12 @@ func (ec *PolicyExecutionContext) buildRequestContexts(headers *extprocv3.HttpHe // Compressed requests are allowed into the streaming path — the body is // decompressed before policies run and recompressed before forwarding to // the upstream, preserving the original Content-Encoding header. - if ec.policyChain.SupportsRequestStreaming && isStreamingClientRequest(wrappedHeaders) { + // + // A route whose chain is selected at the body phase never streams the request: + // the resolver needs the whole body buffered before the chain even exists, so + // there is no chain to ask about streaming support and the mode is fixed to + // BUFFERED. + if ec.policyChain != nil && ec.policyChain.SupportsRequestStreaming && isStreamingClientRequest(wrappedHeaders) { ec.isStreamingRequest = true } } @@ -1436,6 +1514,18 @@ func isStreamingUpstreamResponse(headers *policy.Headers) bool { return true } } + return isServerSentEventResponse(headers) +} + +// isServerSentEventResponse reports whether the upstream response is an SSE stream. +// +// This is the *positive* streaming signal, as distinct from the chunked heuristic above: +// chunked is a transfer framing that an ordinary unary response can perfectly well use — +// a JSON error body is often chunked — whereas text/event-stream says the response is a +// stream of events and nothing else. An operation that declares itself streaming is held +// to this signal, so a chunked JSON error on such an operation is treated as the unary +// response it is. +func isServerSentEventResponse(headers *policy.Headers) bool { if ctValues := headers.Get("content-type"); len(ctValues) > 0 { if strings.HasPrefix(strings.ToLower(ctValues[0]), "text/event-stream") { return true diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go index 3ac585602..1a4e3ce41 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc.go @@ -44,6 +44,8 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/tracing" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) @@ -66,7 +68,11 @@ type ExternalProcessorServer struct { maxResponseDecompressedBytes int64 } -// NewExternalProcessorServer creates a new ExternalProcessorServer +// NewExternalProcessorServer creates a new ExternalProcessorServer. +// +// It takes no resolver registry: resolvers are prepared per route at xDS ingest, so +// nothing on the request path looks one up by name. A route that could not be prepared +// never reaches the kernel. func NewExternalProcessorServer(kernel *Kernel, chainExecutor *executor.ChainExecutor, tracingConfig config.TracingConfig, tracingServiceName string, maxRequestDecompressedBytes int64, maxResponseDecompressedBytes int64) *ExternalProcessorServer { // Initialize tracer once - will be NoOp if tracing is disabled serviceName := tracingServiceName @@ -190,7 +196,7 @@ func (s *ExternalProcessorServer) handleProcessingPhase(ctx context.Context, req defer span.End() // Initialize execution context for this request - rm := s.initializeExecutionContext(ctx, req, execCtx) + rm, outcome, denial := s.initializeExecutionContext(ctx, req, execCtx) if parentSpan.IsRecording() { parentSpan.SetAttributes( attribute.String(constants.AttrRouteName, rm.RouteName), @@ -199,11 +205,43 @@ func (s *ExternalProcessorServer) handleProcessingPhase(ctx context.Context, req attribute.String(constants.AttrAPIContext, rm.Context), attribute.String(constants.AttrOperationPath, rm.OperationPath), ) + // A deferred route has no chain yet, so only the resolver is known here; + // the chain key is stamped from the request-body branch once binding + // actually happens (recordResolutionAttributes). + if *execCtx != nil { + (*execCtx).recordResolutionAttributes(parentSpan) + } } // Track request metrics metrics.RequestsTotal.WithLabelValues("request_headers", rm.RouteName, rm.APIName, rm.APIVersion).Inc() + // Resolution ran and failed: answered with the sterile generic response, the + // failure kind reaching only the log, the metric and the span. Never a fallback + // to the route-level chain — that would silently apply the wrong policies. + if outcome == bindFailed { + resp, failureOutcome := renderResolutionFailure(ctx, denial.resolverName, rm.RouteName, "", + denial.failure) + tracing.RecordHTTPOutcome(span, failureOutcome) + tracing.RecordHTTPOutcome(parentSpan, failureOutcome) + metrics.RequestDurationSeconds.WithLabelValues("request_headers", rm.RouteName).Observe(time.Since(startTime).Seconds()) + if slog.Default().Enabled(ctx, slog.LevelDebug) { + slog.DebugContext(ctx, "ext_proc response", "phase", "request_headers", "resp", prototext.Format(resp)) + } + return resp, nil + } + + // The route's resolver needs the request body: retain the request, tell Envoy + // to buffer, and run nothing until the body arrives. + if outcome == bindPending { + resp := pendingResolutionResponse((*execCtx).pending.prepared.Requirements) + metrics.RequestDurationSeconds.WithLabelValues("request_headers", rm.RouteName).Observe(time.Since(startTime).Seconds()) + if slog.Default().Enabled(ctx, slog.LevelDebug) { + slog.DebugContext(ctx, "ext_proc response", "phase", "request_headers", "resp", prototext.Format(resp)) + } + return resp, nil + } + // If no execution context (no policy chain found), return 500 if *execCtx == nil { if span.IsRecording() { @@ -299,6 +337,16 @@ func (s *ExternalProcessorServer) handleProcessingPhase(ctx context.Context, req } resp, err := (*execCtx).processRequestBody(ctx, req.GetRequestBody()) + + // A route whose chain is selected at this callback only learns its chain key + // here, and it is the attribute that answers "which chain did this operation + // get?" — the whole point of the resolver observability. Stamped on both the + // phase span and the request's root span, since the header phase could not. + if (*execCtx).boundAtBodyPhase { + (*execCtx).recordResolutionAttributes(span) + (*execCtx).recordResolutionAttributes(parentSpan) + } + metrics.RequestDurationSeconds.WithLabelValues("request_body", routeName).Observe(time.Since(startTime).Seconds()) if span.IsRecording() { if err != nil { @@ -471,49 +519,168 @@ func (s *ExternalProcessorServer) handleProcessingPhase(ctx context.Context, req } } -// initializeExecutionContext sets up the execution context for a request by retrieving the policy chain. -// Route metadata is pre-loaded via xDS RouteConfigs — no request-time parsing needed. -func (s *ExternalProcessorServer) initializeExecutionContext(ctx context.Context, req *extprocv3.ProcessingRequest, execCtx **PolicyExecutionContext) *RouteMetadata { +// initializeExecutionContext sets up the execution context for a request by +// selecting its policy chain. Route metadata is pre-loaded via xDS RouteConfigs — +// no request-time parsing needed. +// +// The returned outcome tells the caller which of four things happened; see +// routeBindOutcome. The failure value is non-nil only for bindFailed. +func (s *ExternalProcessorServer) initializeExecutionContext( + ctx context.Context, + req *extprocv3.ProcessingRequest, + execCtx **PolicyExecutionContext, +) (*RouteMetadata, routeBindOutcome, *resolutionDenial) { // Extract route key from Envoy attributes (just xds.route_name, lightweight) routeKey := s.extractRouteKey(req) slog.DebugContext(ctx, "initializeExecutionContext: looking up route", "route_key", routeKey) - // Try new path: RouteConfigs + PolicyChains - if rc := s.kernel.GetRouteConfig(routeKey); rc != nil { - // Metadata is pre-populated from xDS — no request-time parsing needed - routeMetadata := rc.Metadata - routeMetadata.RouteName = routeKey - - // Resolve policy chain key (route-key resolver: policyChainKey = routeKey) - policyChainKey := routeKey // For route-key resolver, this is always the same - - chain := s.kernel.GetPolicyChain(policyChainKey) - if chain == nil { - slog.DebugContext(ctx, "No policy chain found for route (new path)", - "route", routeKey, - "api_name", routeMetadata.APIName) - *execCtx = nil - return &routeMetadata - } - - *execCtx = newPolicyExecutionContext(s, routeKey, chain) - (*execCtx).defaultUpstreamCluster = routeMetadata.DefaultUpstreamCluster - (*execCtx).upstreamBasePath = routeMetadata.UpstreamBasePath - (*execCtx).apiContext = routeMetadata.Context - (*execCtx).upstreamDefinitionPaths = routeMetadata.UpstreamDefinitionPaths - (*execCtx).defaultUpstream = routeMetadata.DefaultUpstream - (*execCtx).buildRequestContexts(req.GetRequestHeaders(), routeMetadata) - return &routeMetadata + rc := s.kernel.GetRouteConfig(routeKey) + if rc == nil { + // No RouteConfig found for this route key — empty metadata, nil exec context + slog.DebugContext(ctx, "initializeExecutionContext: RouteConfig not found", + "route_key", routeKey) + *execCtx = nil + return &RouteMetadata{RouteName: routeKey}, bindNoChain, nil } - // No RouteConfig found for this route key — return empty metadata with nil exec context - slog.DebugContext(ctx, "initializeExecutionContext: RouteConfig not found", - "route_key", routeKey) - routeMetadata := RouteMetadata{RouteName: routeKey} - *execCtx = nil - return &routeMetadata + // Metadata is pre-populated from xDS — no request-time parsing needed + routeMetadata := rc.Metadata + routeMetadata.RouteName = routeKey + + prepared := rc.Prepared + if prepared == nil { + // Ingest drops a route it could not prepare, so a RouteConfig without a prepared + // resolver reached the kernel by some other path. Deny rather than guessing at a + // chain: falling back to the route key would select a route-level chain for every + // logical operation a multiplexed route carries. + slog.ErrorContext(ctx, "Route has no prepared resolver", + "route", routeKey, "resolver", rc.ResolverName) + *execCtx = nil + return &routeMetadata, bindFailed, &resolutionDenial{ + resolverName: rc.ResolverName, + failure: &resolver.ResolutionError{Kind: resolver.FailureUnknownResolver}, + } + } + + // A resolution known at ingest — every API kind shipping today, via route-key — + // binds from the stored result. No request view is built and Resolve is never + // called; the cost is the same field read and string comparison as before + // per-route resolvers existed. + // + // This precedes the body-requirement check below, which is only safe because + // PrepareRoute refuses a static resolver that declares it needs anything from the + // request: there is no combination where taking this branch skips a requirement the + // resolver stated. Do not reorder these two without moving that rule. + if prepared.IsStatic() { + return s.bindStaticRoute(ctx, routeKey, rc, prepared, req, routeMetadata, execCtx) + } + + view := buildRequestView(routeKey, req.GetRequestHeaders()) + + // A body-reading resolver normally defers to the request-body callback. It must + // not defer when the request headers are end-of-stream: Envoy sends no + // request-body callback for a bodyless request, so a pending request would wait + // for a callback that cannot occur. Resolve (or deny) here instead — for a + // JSON-RPC route an empty body is an invalid request anyway. + // + // So a BodyBuffered resolver can be called with RequestView.Body nil, which is why + // resolver.PreparedResolver.Resolve requires every resolver to tolerate that. + if prepared.Requirements.BuffersBody() && !req.GetRequestHeaders().GetEndOfStream() { + ec := s.newBoundExecutionContext(routeKey, rc, "", nil, req, routeMetadata) + ec.pending = &pendingResolution{route: rc, prepared: prepared, view: view} + *execCtx = ec + slog.DebugContext(ctx, "[resolution] deferring chain selection to the request-body phase", + "route", routeKey, "resolver", prepared.ResolverName) + return &routeMetadata, bindPending, nil + } + + resolution, err := prepared.Resolver.Resolve(ctx, view) + if err != nil { + *execCtx = nil + return &routeMetadata, bindFailed, newResolutionDenial(prepared, + resolver.NormalizeResolutionError(err)) + } + + bound, chain, err := resolver.Bind(prepared, resolution, s.kernel.GetPolicyChain) + if err != nil { + *execCtx = nil + if errors.Is(err, resolver.ErrDirectRouteChainMissing) { + // The route resolves directly and has no chain: the pre-resolution outcome, + // whose sterile 500 must stay byte-identical. + slog.DebugContext(ctx, "No policy chain found for route", + "route", routeKey, "api_name", routeMetadata.APIName) + return &routeMetadata, bindNoChain, nil + } + return &routeMetadata, bindFailed, newResolutionDenial(prepared, + resolver.NormalizeResolutionError(err)) + } + + ec := s.newBoundExecutionContext(routeKey, rc, bound.ChainKey, chain, req, routeMetadata) + ec.operation = bound.Operation + *execCtx = ec + return &routeMetadata, bindReady, nil +} + +// bindStaticRoute binds a route whose resolution was fully determined at ingest. +// +// The structural work — checking that a direct target names this route's own chain key, or +// that an operation target belongs to this API and vhost — happened once, at preparation, +// and PrepareRoute refused the route if it failed. So a +// statically-prepared resolver still cannot reach another route's chain, and the request +// pays for none of that: one chain lookup and a struct copy, which is what the path cost +// before per-route resolvers existed. +func (s *ExternalProcessorServer) bindStaticRoute( + ctx context.Context, + routeKey string, + rc *RouteConfig, + prepared *resolver.PreparedRoute, + req *extprocv3.ProcessingRequest, + routeMetadata RouteMetadata, + execCtx **PolicyExecutionContext, +) (*RouteMetadata, routeBindOutcome, *resolutionDenial) { + bound, chain, err := resolver.BindStatic(prepared, s.kernel.GetPolicyChain) + if err != nil { + *execCtx = nil + if errors.Is(err, resolver.ErrDirectRouteChainMissing) { + // The route has no policy chain. This is the pre-existing sterile 500 path + // and its response must stay byte-identical. + slog.DebugContext(ctx, "No policy chain found for route", + "route", routeKey, "api_name", routeMetadata.APIName) + return &routeMetadata, bindNoChain, nil + } + return &routeMetadata, bindFailed, newResolutionDenial(prepared, + resolver.NormalizeResolutionError(err)) + } + + ec := s.newBoundExecutionContext(routeKey, rc, bound.ChainKey, chain, req, routeMetadata) + ec.operation = bound.Operation + *execCtx = ec + return &routeMetadata, bindReady, nil +} + +// newBoundExecutionContext allocates the execution context for a request and copies +// the route's pre-resolved metadata into it. chain may be nil for a route whose +// chain is selected later, at the request-body callback. +func (s *ExternalProcessorServer) newBoundExecutionContext( + routeKey string, + rc *RouteConfig, + chainKey string, + chain *registry.PolicyChain, + req *extprocv3.ProcessingRequest, + routeMetadata RouteMetadata, +) *PolicyExecutionContext { + ec := newPolicyExecutionContext(s, routeKey, chain) + ec.chainKey = chainKey + ec.resolverName = rc.ResolverName + ec.defaultUpstreamCluster = routeMetadata.DefaultUpstreamCluster + ec.upstreamBasePath = routeMetadata.UpstreamBasePath + ec.apiContext = routeMetadata.Context + ec.upstreamDefinitionPaths = routeMetadata.UpstreamDefinitionPaths + ec.defaultUpstream = routeMetadata.DefaultUpstream + ec.buildRequestContexts(req.GetRequestHeaders(), routeMetadata) + return ec } // extractRouteKey extracts just the route key (xds.route_name) from the request attributes. diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_bench_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_bench_test.go index c08faebb4..21ca65ec0 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_bench_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_bench_test.go @@ -213,11 +213,11 @@ func buildPolicyChain(policies []policy.Policy, specs []policy.PolicySpec) *regi } } return ®istry.PolicyChain{ - Policies: policies, - PolicySpecs: specs, - RequiresRequestBody: false, - RequiresResponseBody: false, - HasExecutionConditions: hasExecutionConditions, + Policies: policies, + PolicySpecs: specs, + RequiresRequestBody: false, + RequiresResponseBody: false, + HasExecutionConditions: hasExecutionConditions, } } @@ -496,9 +496,9 @@ func BenchmarkGetModeOverride(b *testing.B) { buildPolicySpec("p1", "v1.0", nil), buildPolicySpec("p2", "v1.0", nil), }, - RequiresRequestBody: false, - RequiresResponseBody: false, - HasExecutionConditions: false, + RequiresRequestBody: false, + RequiresResponseBody: false, + HasExecutionConditions: false, } server := newBenchServer(nil) diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_span_status_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_span_status_test.go index 8eefb8f60..aaa5a0238 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_span_status_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_span_status_test.go @@ -39,6 +39,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" ) @@ -123,9 +124,13 @@ func newSpanStatusServerWithCEL(t *testing.T, cel executor.CELEvaluator) (*Exter // RouteConfig lookup that initializeExecutionContext needs. func registerTestRoute(k *Kernel, routeName string, chain *registry.PolicyChain) { k.RegisterRoute(routeName, chain) - k.ApplyWholeRouteConfigs(map[string]*RouteConfig{ - routeName: {Metadata: RouteMetadata{RouteName: routeName}}, - }) + rc := &RouteConfig{Metadata: RouteMetadata{RouteName: routeName}} + // Prepared the same way ingest prepares it: an unprepared route is one the kernel + // refuses to serve. + if err := PrepareRoute(resolver.DefaultRegistry(), routeName, rc); err != nil { + panic(err) + } + k.ApplyWholeRouteConfigs(map[string]*RouteConfig{routeName: rc}) } // buildChainWithPolicy constructs a single-policy chain via the real diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_test.go index e7decbc2a..0c1bc580a 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/extproc_test.go @@ -37,6 +37,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" ) @@ -441,9 +442,11 @@ func TestInitializeExecutionContext_NoPolicyChain(t *testing.T) { var execCtx *PolicyExecutionContext - routeMeta := server.initializeExecutionContext(context.Background(), req, &execCtx) + routeMeta, outcome, denial := server.initializeExecutionContext(context.Background(), req, &execCtx) assert.Nil(t, execCtx) + assert.Equal(t, bindNoChain, outcome) + assert.Nil(t, denial) assert.Equal(t, "nonexistent-route", routeMeta.RouteName) } @@ -455,9 +458,9 @@ func TestInitializeExecutionContext_WithPolicyChain(t *testing.T) { PolicySpecs: []policy.PolicySpec{}, } kernel.RegisterRoute("test-route", chain) - kernel.ApplyWholeRouteConfigs(map[string]*RouteConfig{ - "test-route": {Metadata: RouteMetadata{RouteName: "test-route"}}, - }) + rc := &RouteConfig{Metadata: RouteMetadata{RouteName: "test-route"}} + require.NoError(t, PrepareRoute(resolver.DefaultRegistry(), "test-route", rc)) + kernel.ApplyWholeRouteConfigs(map[string]*RouteConfig{"test-route": rc}) chainExecutor := executor.NewChainExecutor(nil, nil, nil) server := NewExternalProcessorServer(kernel, chainExecutor, config.TracingConfig{}, "", testMaxDecompressedBytes, testMaxDecompressedBytes) @@ -487,9 +490,11 @@ func TestInitializeExecutionContext_WithPolicyChain(t *testing.T) { var execCtx *PolicyExecutionContext - routeMeta := server.initializeExecutionContext(context.Background(), req, &execCtx) + routeMeta, outcome, denial := server.initializeExecutionContext(context.Background(), req, &execCtx) require.NotNil(t, execCtx) + assert.Equal(t, bindReady, outcome) + assert.Nil(t, denial) assert.Equal(t, "test-route", routeMeta.RouteName) assert.Equal(t, "test-route", execCtx.routeKey) assert.Equal(t, "req-123", execCtx.requestID) diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/kernel_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/kernel_test.go index 2bf07b6c1..e2d1d6011 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/kernel_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/kernel_test.go @@ -506,7 +506,7 @@ func TestBuildAnalyticsStruct_EmptyData(t *testing.T) { func TestBuildAnalyticsStruct_SimpleData(t *testing.T) { data := map[string]any{ - "requestId": "req-123", + "requestId": "req-123", "statusCode": 200, } diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/mapper.go b/gateway/gateway-runtime/policy-engine/internal/kernel/mapper.go index 32abd271e..42f8fb3b0 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/mapper.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/mapper.go @@ -20,15 +20,114 @@ package kernel import ( "log/slog" + "strings" "sync" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" ) // RouteConfig holds metadata and resolver info for a single route. // Metadata is pre-populated at deploy time; no request-time parsing needed. type RouteConfig struct { Metadata RouteMetadata + + // RouteResolution carries how this route's policy chain key is derived — + // RouteKey, CanonicalChainKey, ResolverName, ResolverConfig and the prepared + // resolver built from them at ingest. Embedded so the fields read directly off + // the route (rc.CanonicalChainKey, rc.Prepared) without copying the struct per + // request. + resolver.RouteResolution + + // MaxRequestBodyBytes is the largest request body, in wire bytes before any + // decompression, that this route will accept for operation resolution. Zero means + // DefaultMaxResolverRequestBodyBytes applies. + // + // It is an *acceptance* ceiling, not a buffering one, and the distinction matters. + // A body-resolved route asks Envoy for BUFFERED mode, so by the time this is + // checked Envoy has already collected the whole body and shipped it here in one + // ext_proc message. What this bounds is therefore the work done *on* the body — + // decompression, resolver parsing, and the copies those make — plus it returns a + // clean 413 instead of letting an oversized body reach a resolver. + // + // What it does NOT bound is the memory an unauthenticated caller can make the + // gateway hold: that is Envoy's listener-wide + // router.http_listener.per_connection_buffer_limit_bytes (1 MiB by default) and the + // ext_proc gRPC server's receive limit, neither of which is per-route. Lowering + // this value does not lower that. Making it a real buffering bound needs either an + // Envoy-side per-route cap (the buffer filter's max_request_bytes, which returns 413 + // before ext_proc collects the body) or streamed accumulation in the engine; neither + // is built. + MaxRequestBodyBytes int64 +} + +// PrepareRoute prepares rc's resolver from the fields that arrived over the wire, and +// stores the result on rc. +// +// It is the one place a ResolverRouteConfig is built, so a Prepare implementation and +// the binder's own validation always see the same values. Two of those values are +// derived here rather than read from the wire: +// +// - the effective chain key, applying the older-controller fallback to the route key +// exactly once — nothing downstream re-applies it, so nothing can disagree with it; +// - the HTTP method, read out of the Envoy route name (METHOD|fullPath|vhost), which +// is its only source: a route carries its path in metadata but not its method. +// +// An error means the route is unusable and its caller must drop it. Callers distinguish +// an unknown resolver from a resolver's own failure via resolver.FailureUnknownResolver. +func PrepareRoute(reg resolver.ResolverRegistry, routeKey string, rc *RouteConfig) error { + rc.RouteKey = routeKey + if rc.CanonicalChainKey == "" { + rc.CanonicalChainKey = routeKey + } + + prepared, err := resolver.PrepareRoute(reg, resolver.ResolverRouteConfig{ + RouteKey: routeKey, + CanonicalChainKey: rc.CanonicalChainKey, + ResolverName: rc.ResolverName, + APIID: rc.Metadata.APIId, + Vhost: rc.Metadata.Vhost, + APIContext: rc.Metadata.Context, + // Normalised once, here, so no Prepare implementation can miss on case + // (GO-AUTH-006). + Method: strings.ToUpper(methodFromRouteKey(routeKey)), + Path: rc.Metadata.OperationPath, + ResolverConfig: rc.ResolverConfig, + }) + if err != nil { + return err + } + rc.Prepared = prepared + return nil +} + +// methodFromRouteKey reads the HTTP method out of an Envoy route name. +// +// A key with no separator yields no method rather than the whole key, so a +// differently-shaped route name degrades to "unknown" instead of handing a resolver a +// method that is really a path. +func methodFromRouteKey(routeKey string) string { + method, _, found := strings.Cut(routeKey, "|") + if !found { + return "" + } + return method +} + +// DefaultMaxResolverRequestBodyBytes is the acceptance ceiling applied to a +// body-resolved route whose RouteConfig carries no explicit limit. Deliberately far +// below Envoy's per-connection buffer limit: on these routes the body is resolved, and +// therefore parsed, before any authentication policy has run. +const DefaultMaxResolverRequestBodyBytes int64 = 64 * 1024 + +// EffectiveMaxRequestBodyBytes returns the acceptance ceiling actually in force for this +// route, resolving the default. Exported so the admin config dump can report the bound +// that applies rather than the raw (possibly zero) configured value. +func (rc *RouteConfig) EffectiveMaxRequestBodyBytes() int64 { + if rc == nil || rc.MaxRequestBodyBytes <= 0 { + return DefaultMaxResolverRequestBodyBytes + } + return rc.MaxRequestBodyBytes } // RouteMapping maps Envoy metadata keys to PolicyChains for route-specific processing diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/resolution.go b/gateway/gateway-runtime/policy-engine/internal/kernel/resolution.go new file mode 100644 index 000000000..1667c551c --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/resolution.go @@ -0,0 +1,559 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package kernel + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "sort" + "strings" + + extprocconfigv3 "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" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/tracing" + policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" +) + +// routeBindOutcome describes what the request-headers callback established about a +// request's policy chain. +type routeBindOutcome int + +const ( + // bindReady means a policy chain is selected and the execution context is ready + // to process phases normally. Every kind shipping today takes this path. + bindReady routeBindOutcome = iota + + // bindPending means the route's resolver must read the request body, so no chain + // is selected yet: the execution context exists with a nil chain, no policy has + // run, and Envoy is asked to buffer the body and come back. + bindPending + + // bindNoChain means there is no RouteConfig for this route key, or the route + // resolves by identity and has no policy chain. This is the pre-existing sterile + // 500 path and its response must stay byte-identical. + bindNoChain + + // bindFailed means resolution ran and failed. The response is the sterile generic + // one; the failure kind reaches the log, the metric and the span, never the client. + bindFailed +) + +// resolutionDenial carries everything the request-headers callback needs to render +// a resolution failure, captured at the point of failure so the caller never has to +// look the resolver up a second time (and cannot look up a different one). +type resolutionDenial struct { + resolverName string + failure *resolver.ResolutionError +} + +// newResolutionDenial records a failure against the route that produced it. +func newResolutionDenial(pr *resolver.PreparedRoute, failure *resolver.ResolutionError) *resolutionDenial { + return &resolutionDenial{resolverName: pr.ResolverName, failure: failure} +} + +// pendingResolution is the retained state of a request whose policy chain cannot be +// selected at the request-headers callback because the route's resolver must read +// the request body. +// +// Nothing is forwarded upstream while this is set, so ordering guarantees relative +// to the backend still hold: the resolved chain's request-header policies simply run +// one callback later than they would on an identity route. +type pendingResolution struct { + route *RouteConfig + prepared *resolver.PreparedRoute + + // view is the header-phase request view, retained so the resolver observes the + // headers, method and path as the client actually sent them rather than values + // re-derived at the body callback. + view resolver.RequestView +} + +// buildRequestView snapshots what a resolver is allowed to see about a request. +// +// Only called for a route whose resolution is not already known: a statically-resolved +// route (every kind shipping today, via route-key) binds from the result captured at +// ingest and must not pay for this allocation. +// +// The route's partition — API ID, vhost, API context — is deliberately absent: the +// prepared resolver captured it at ingest, so a resolver cannot be handed a partition +// that differs from the one its keys are validated against. +func buildRequestView(routeKey string, headers *extprocv3.HttpHeaders) resolver.RequestView { + view := resolver.RequestView{RouteKey: routeKey} + + if headers == nil || headers.Headers == nil { + return view + } + + hdrs := make(map[string][]string, len(headers.Headers.GetHeaders())) + for _, h := range headers.Headers.GetHeaders() { + value := string(h.RawValue) + hdrs[h.Key] = append(hdrs[h.Key], value) + switch h.Key { + case ":method": + // Normalized once, here, so no downstream comparison or map lookup can + // miss on case (GO-AUTH-006). + view.Method = strings.ToUpper(value) + case ":path": + view.Path = value + } + } + view.Headers = hdrs + return view +} + +// pendingModeOverride is the ProcessingMode returned from the request-headers +// callback of a route whose chain is not selected yet. +// +// It is derived from the prepared resolver's Requirements, not from a policy chain — +// there is no chain yet. That is also why no controller-side ExtProcPerRoute processing +// mode override (or equivalent route flag) is used for this: it would duplicate the +// route's body requirement in a second place that can disagree with it. +// +// Response modes are left at NONE and revisited from the response-header callback, +// once the chain is known: Envoy applies a ModeOverride only on responses to header +// callbacks, and the response-header callback still precedes its decision about how +// to deliver the upstream response body. +func pendingModeOverride(reqs resolver.RequestRequirements) *extprocconfigv3.ProcessingMode { + mode := &extprocconfigv3.ProcessingMode{ + ResponseHeaderMode: extprocconfigv3.ProcessingMode_SEND, + RequestTrailerMode: extprocconfigv3.ProcessingMode_SKIP, + ResponseTrailerMode: extprocconfigv3.ProcessingMode_SKIP, + ResponseBodyMode: extprocconfigv3.ProcessingMode_NONE, + } + if reqs.BuffersBody() { + mode.RequestBodyMode = extprocconfigv3.ProcessingMode_BUFFERED + } else { + mode.RequestBodyMode = extprocconfigv3.ProcessingMode_NONE + } + return mode +} + +// pendingResolutionResponse is the request-headers response for a deferred route: +// no mutation, no policy result, no analytics — only the instruction to buffer the +// body and call back. +func pendingResolutionResponse(reqs resolver.RequestRequirements) *extprocv3.ProcessingResponse { + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{}, + }, + ModeOverride: pendingModeOverride(reqs), + } +} + +// bindPendingChainAndProcess runs at the request-body callback of a deferred route. +// It enforces the body ceilings, resolves the operation from the decoded bytes, +// binds the chain, and then runs the chain's request-header policies followed by its +// request-body policies — all emitted on this one body-phase response. +func (ec *PolicyExecutionContext) bindPendingChainAndProcess( + ctx context.Context, + body *extprocv3.HttpBody, +) (*extprocv3.ProcessingResponse, error) { + pending := ec.pending + wire := body.GetBody() + + // Acceptance ceiling, checked before the body is decompressed or parsed. On a + // body-resolved route none of that work is authenticated — the chain holding + // jwt-auth is what we are still trying to find — so an unauthenticated caller + // controls how much of it happens. + // + // This bounds the work, not the buffering: BUFFERED mode means Envoy has already + // collected the whole body and sent it here in one message by the time this runs. + // The memory an unauthenticated caller can pin is bounded by Envoy's listener-wide + // per_connection_buffer_limit_bytes and the ext_proc gRPC receive limit, neither of + // which is per-route. See RouteConfig.MaxRequestBodyBytes. + if limit := pending.route.EffectiveMaxRequestBodyBytes(); int64(len(wire)) > limit { + return ec.denyResolution(ctx, &resolver.ResolutionError{ + Kind: resolver.FailurePayloadTooLarge, + // The configured limit is never echoed to the client (file-access.md + // directive 5); only the observed size is logged internally. + Cause: fmt.Errorf("request body of %d wire bytes exceeds this route's acceptance limit", len(wire)), + }), nil + } + + decoded, decodeFailure := ec.decodeResolverRequestBody(wire, pending.view.Headers) + if decodeFailure != nil { + return ec.denyResolution(ctx, decodeFailure), nil + } + + view := pending.view + view.Body = decoded + + resolution, err := pending.prepared.Resolver.Resolve(ctx, view) + if err != nil { + return ec.denyResolution(ctx, resolver.NormalizeResolutionError(err)), nil + } + + bound, chain, err := resolver.Bind(pending.prepared, resolution, ec.server.kernel.GetPolicyChain) + if err != nil { + if errors.Is(err, resolver.ErrDirectRouteChainMissing) { + // A route whose resolution is direct has no chain of its own. Not reachable + // from a deferred route today — the only direct resolver is static and never + // defers — but it is classified as the same deployment fault a direct route + // gets elsewhere rather than as something the caller did. + return ec.denyResolution(ctx, &resolver.ResolutionError{ + Kind: resolver.FailureChainMissing, + Cause: err, + }), nil + } + return ec.denyResolution(ctx, resolver.NormalizeResolutionError(err)), nil + } + + // Bind. + ec.policyChain = chain + ec.chainKey = bound.ChainKey + ec.operation = bound.Operation + ec.pending = nil + ec.boundAtBodyPhase = true + + slog.DebugContext(ctx, "[resolution] chain bound at request-body phase", + "route", ec.routeKey, "resolver", ec.resolverName, "chain_key", bound.ChainKey, + "operation", bound.Operation, "decoded_bytes", len(decoded)) + + // Reuse the decoded body for the body policies so it is never decompressed + // twice. EndOfStream is true here by construction: the mode is BUFFERED, so + // Envoy delivers the whole body in one callback. + ec.requestBodyCtx.Body = &policy.Body{ + Content: decoded, + EndOfStream: body.GetEndOfStream(), + Present: true, + } + + headerResult, err := ec.server.executor.ExecuteRequestHeaderPolicies( + ctx, + ec.policyChain.Policies, + ec.requestHeaderCtx, + ec.policyChain.PolicySpecs, + ec.sharedCtx.APIName, + ec.routeKey, + ec.policyChain.HasExecutionConditions, + ) + if err != nil { + return ec.handlePolicyError(ctx, err, "request_headers_deferred"), nil + } + + if !headerResult.ShortCircuited { + applyRequestHeaderMutations(ec.requestHeaderCtx.Headers, headerResult.Results) + ec.syncRequestPseudoHeaders() + } + + // An empty body result is still a valid merge input: a chain with no body policy + // contributes no body mutation, and the header-phase mutations it did produce + // still have to be emitted on this response. + bodyResult := &executor.RequestExecutionResult{} + if !headerResult.ShortCircuited && ec.policyChain.RequiresRequestBody { + bodyResult, err = ec.server.executor.ExecuteRequestPolicies( + ctx, + ec.policyChain.Policies, + ec.requestBodyCtx, + ec.policyChain.PolicySpecs, + ec.sharedCtx.APIName, + ec.routeKey, + ec.policyChain.HasExecutionConditions, + ) + if err != nil { + return ec.handlePolicyError(ctx, err, "request_body"), nil + } + } + + return TranslateRequestBodyActionsWithHeaderMerge(headerResult, bodyResult, ec) +} + +// denyResolution builds the response for a request whose operation could not be +// resolved to a policy chain, and marks the execution context as never having bound +// one. +// +// Falling back to identity resolution here is forbidden: it would select the +// route-level chain for every logical operation on a multiplexed route and appear +// to work, which is exactly the silent policy bypass this whole mechanism exists to +// prevent. +func (ec *PolicyExecutionContext) denyResolution( + ctx context.Context, + failure *resolver.ResolutionError, +) *extprocv3.ProcessingResponse { + resp, outcome := renderResolutionFailure(ctx, ec.resolverName, ec.routeKey, ec.requestID, failure) + + // The chain is never bound for this request. Later phases check this so a + // response callback that arrives anyway cannot dereference a nil chain. + ec.pending = nil + ec.policyChain = nil + ec.resolutionDenied = true + ec.generated = generatedResponse{resp: resp, outcome: outcome} + ec.terminal = outcome + return resp +} + +// renderResolutionFailure turns a typed resolution failure into an ext_proc +// ImmediateResponse plus the span outcome that describes it. +// +// Every failure renders as the sterile generic response: an HTTP status derived from the +// FailureKind, a fixed reason phrase and a correlation id that also appears in the +// warning log. The kind survives in the log and the metric, never in the body, and the +// failure's Cause is logged and never returned (error-handling.md directive 1). +func renderResolutionFailure( + ctx context.Context, + resolverName string, + routeKey string, + requestID string, + failure *resolver.ResolutionError, +) (*extprocv3.ProcessingResponse, tracing.HTTPOutcome) { + errorID := uuid.New().String() + + slog.WarnContext(ctx, "Operation resolution failed", + "error_id", errorID, + "request_id", requestID, + "route", routeKey, + "resolver", resolverName, + "kind", string(failure.Kind), + "error", failure.Cause, + ) + metrics.ResolutionFailuresTotal.WithLabelValues(resolverName, string(failure.Kind)).Inc() + + rendered := genericResolutionFailure(failure.Kind, errorID) + + imm := &extprocv3.ImmediateResponse{ + Status: &typev3.HttpStatus{Code: typev3.StatusCode(rendered.StatusCode)}, + Headers: buildHeaderValueOptions(rendered.Headers), + Body: rendered.Body, + } + resp := &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ImmediateResponse{ImmediateResponse: imm}, + } + return resp, tracing.HTTPOutcome{ + StatusCode: rendered.StatusCode, + Reason: constants.TerminalReasonResolutionFailed, + ErrorID: errorID, + } +} + +// sterileFailure is the kernel's own generic error response. It is deliberately not part +// of the resolver contract: a resolver classifies a failure and never shapes one. +type sterileFailure struct { + StatusCode int + Headers map[string]string + Body []byte +} + +// genericResolutionFailure is the sterile response for a resolution failure: an +// HTTP status, a fixed reason phrase, and a correlation id that also appears in the +// warning log. It never names the resolver, the operation, or the underlying cause. +func genericResolutionFailure(kind resolver.FailureKind, errorID string) sterileFailure { + status := http.StatusInternalServerError + message := "Internal Server Error" + + switch kind { + case resolver.FailureParse, resolver.FailureInvalidRequest, resolver.FailureMultiOperation, + resolver.FailureUndecodableBody: + status, message = http.StatusBadRequest, "Bad Request" + case resolver.FailureUnknownOperation: + status, message = http.StatusNotFound, "Not Found" + case resolver.FailurePayloadTooLarge: + status, message = http.StatusRequestEntityTooLarge, "Payload Too Large" + case resolver.FailureUnsupportedEncoding: + // The client named a coding this gateway cannot decode; 415 says exactly that, + // where a 400 or 500 would send them looking at their payload or at us. + status, message = http.StatusUnsupportedMediaType, "Unsupported Media Type" + } + + return sterileFailure{ + StatusCode: status, + Headers: map[string]string{ + "content-type": "application/json", + "x-error-id": errorID, + }, + Body: []byte(fmt.Sprintf(`{"error":%q,"error_id":%q}`, message, errorID)), + } +} + +// ─── Pass-throughs for a request that never bound a policy chain ───────────── +// +// Reachable only if Envoy delivers a callback after an ImmediateResponse, or if a +// deferred route's request-body callback never arrives. Each returns the phase's +// empty response — the same shape the "no execution context" branches in +// handleProcessingPhase already return — so an unexpected callback ordering can +// never turn into a nil-chain dereference mid-stream. + +func (ec *PolicyExecutionContext) noChainPassThroughRequestBody() *extprocv3.ProcessingResponse { + slog.Warn("[resolution] request body callback with no bound policy chain", + "route", ec.routeKey, "resolver", ec.resolverName, "denied", ec.resolutionDenied) + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestBody{RequestBody: &extprocv3.BodyResponse{}}, + } +} + +func (ec *PolicyExecutionContext) noChainPassThroughResponseHeaders() *extprocv3.ProcessingResponse { + slog.Warn("[resolution] response headers callback with no bound policy chain", + "route", ec.routeKey, "resolver", ec.resolverName, "denied", ec.resolutionDenied) + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ResponseHeaders{ResponseHeaders: &extprocv3.HeadersResponse{}}, + } +} + +func (ec *PolicyExecutionContext) noChainPassThroughResponseBody() *extprocv3.ProcessingResponse { + slog.Warn("[resolution] response body callback with no bound policy chain", + "route", ec.routeKey, "resolver", ec.resolverName, "denied", ec.resolutionDenied) + return &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ResponseBody{ResponseBody: &extprocv3.BodyResponse{}}, + } +} + +// recordResolutionAttributes stamps the resolver name, the selected chain key and the +// resolved operation on a span, skipping whichever is not known yet. +// +// It is called twice for a body-resolved route — once at the request-headers callback, +// where only the resolver is known, and again at the request-body callback once the +// chain has actually been selected. Skipping an empty attribute rather than recording +// one keeps them meaningful: an operator filtering on the chain key sees only spans +// where a chain was really chosen, instead of every deferred request carrying "". +func (ec *PolicyExecutionContext) recordResolutionAttributes(span trace.Span) { + if span == nil || !span.IsRecording() || ec.resolverName == "" { + // An identity route has no resolver, and its chain key equals the route name + // that is already on the span. + return + } + attrs := []attribute.KeyValue{ + attribute.String(constants.AttrResolverName, ec.resolverName), + } + if ec.chainKey != "" { + attrs = append(attrs, attribute.String(constants.AttrPolicyChainKey, ec.chainKey)) + } + if ec.operation != "" { + attrs = append(attrs, attribute.String(constants.AttrResolvedOperation, ec.operation)) + } + span.SetAttributes(attrs...) +} + +// ─── Request-body decoding for a resolver ──────────────────────────────────── + +// resolverDecodableCodings is the set of content codings the policy engine can +// actually decode. It is deliberately an allowlist rather than a denylist of "bad" +// values: decompressBody returns an unrecognised coding's bytes unchanged and +// reports no error, which is the right lenient behaviour for a body handed to a +// policy but the wrong behaviour for one handed to a resolver. +var resolverDecodableCodings = map[string]bool{ + "gzip": true, + "br": true, +} + +// decodeResolverRequestBody returns the bytes a resolver may read, or the failure to +// deny the request with. +// +// A resolver decides which policy chain runs, so it must never be shown bytes the +// engine did not actually decode: it would resolve to whatever the compressed frame +// happens to look like — most likely nothing, but possibly a different operation than +// the client sent, with a different chain. That is a policy-selection bug, not a +// parsing inconvenience, so an encoding the engine cannot decode is rejected here +// rather than passed through. +// +// This gate is specific to the deferred (body-resolved) path. Identity routes keep +// decompressBody's lenient behaviour — pass the raw bytes to policies and log — because +// there the chain is already selected and no security decision hangs on the body's +// interpretation. +func (ec *PolicyExecutionContext) decodeResolverRequestBody( + wire []byte, + headers map[string][]string, +) ([]byte, *resolver.ResolutionError) { + coding, err := resolverContentCoding(headers) + if err != nil { + return nil, &resolver.ResolutionError{Kind: resolver.FailureUnsupportedEncoding, Cause: err} + } + + if coding == "" { + // No coding, or `identity`, which RFC 9110 defines as the absence of one. + // The decoded ceiling still applies, so the resolver's input is bounded by the + // same number whether or not the request was compressed. + if int64(len(wire)) > ec.server.maxRequestDecompressedBytes { + return nil, &resolver.ResolutionError{ + Kind: resolver.FailurePayloadTooLarge, + Cause: fmt.Errorf("request body %d bytes exceeds the decoded body limit", len(wire)), + } + } + return wire, nil + } + + decoded, err := decompressBody(wire, coding, ec.server.maxRequestDecompressedBytes) + if err != nil { + if errors.Is(err, ErrDecompressedTooLarge) { + return nil, &resolver.ResolutionError{Kind: resolver.FailurePayloadTooLarge, Cause: err} + } + // A body that claims a coding the engine supports but does not decode under it + // is a malformed request, not an engine fault. + return nil, &resolver.ResolutionError{Kind: resolver.FailureUndecodableBody, Cause: err} + } + + // Pin the canonical coding for the rest of the request. The header may have said + // "GZIP" or " gzip"; the recompression path re-encodes a policy-modified body from + // this field and only matches the canonical token, so normalising it here is what + // keeps a mutated body from being forwarded as plaintext under a compressed label. + ec.requestContentEncoding = coding + return decoded, nil +} + +// resolverContentCoding reduces a request's Content-Encoding headers to the single +// coding the body is actually under, or reports why it cannot. +// +// It reads every header line rather than the single value captured at context-build +// time, because a client may split a coding list across lines and the capture keeps +// only the last — which would hide a stacked encoding entirely. Tokens are +// case-folded, since HTTP content codings are case-insensitive and `GZIP` would +// otherwise fall through as "unrecognised" and be passed to the resolver raw. +func resolverContentCoding(headers map[string][]string) (string, error) { + var tokens []string + for name, values := range headers { + if !strings.EqualFold(name, "content-encoding") { + continue + } + for _, value := range values { + for _, part := range strings.Split(value, ",") { + token := strings.ToLower(strings.TrimSpace(part)) + if token == "" || token == "identity" { + continue + } + tokens = append(tokens, token) + } + } + } + + switch len(tokens) { + case 0: + return "", nil + case 1: + if !resolverDecodableCodings[tokens[0]] { + return "", fmt.Errorf("content coding %q cannot be decoded for operation resolution", tokens[0]) + } + return tokens[0], nil + default: + // Stacked codings would need to be unwrapped in order, innermost last. The + // engine decodes exactly one layer, so accepting this would hand the resolver + // a still-encoded body. + sort.Strings(tokens) + return "", fmt.Errorf("stacked content codings %v cannot be decoded for operation resolution", tokens) + } +} diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/resolution_test.go b/gateway/gateway-runtime/policy-engine/internal/kernel/resolution_test.go new file mode 100644 index 000000000..af406af35 --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/resolution_test.go @@ -0,0 +1,1659 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package kernel + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extprocconfigv3 "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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace/noop" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/config" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/executor" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/testutils" + policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" +) + +// ─── Fakes ─────────────────────────────────────────────────────────────────── +// +// The shipped binary registers only the identity resolver, so every non-identity +// path below is exercised through a fake supplied via an independent registry. That +// is what makes the whole seam testable before the first real multiplexed kind +// lands. + +// fakeOperationResolver reads the operation out of a request in whichever way the +// test needs: from a header (header-only, resolves at the header phase) or from a +// JSON body field (body-reading, defers to the body phase). +// It is its own factory and its own prepared resolver: Prepare captures the route's +// partition and returns the same value. That keeps the fixtures small, and the +// independence of two routes prepared by one factory is covered where it belongs, in +// the resolver package's own tests. +type fakeOperationResolver struct { + name string + reqs resolver.RequestRequirements + bodyField string // when set, the operation is read from this top-level JSON field + header string // when set, the operation is read from this header + forcedErr *resolver.ResolutionError + + // apiID and vhost are captured at Prepare, exactly as a real resolver captures the + // partition it composes keys from. + apiID string + vhost string + + // knownToProtocol mirrors a closed operation set (A2A's fixed enum), where a + // missing chain is a deployment error rather than the client naming something that + // does not exist. Left false, this fake behaves like an open set (MCP tool names). + knownToProtocol bool + + seenBody []byte + seenView resolver.RequestView + identified int +} + +func (f *fakeOperationResolver) Name() string { return f.name } + +func (f *fakeOperationResolver) Prepare(cfg resolver.ResolverRouteConfig) (resolver.PreparedResolver, error) { + f.capture(cfg) + return f, nil +} + +// capture records the static route data a real Prepare would keep. +func (f *fakeOperationResolver) capture(cfg resolver.ResolverRouteConfig) { + f.apiID, f.vhost = cfg.APIID, cfg.Vhost +} + +func (f *fakeOperationResolver) Requirements() resolver.RequestRequirements { return f.reqs } + +// resolveOperation composes the resolution for one identified operation, the way a real +// resolver does: with the partition captured at Prepare, never with anything from the +// request. +func (f *fakeOperationResolver) resolveOperation(operation string) resolver.Resolution { + return resolver.Resolution{ + Target: resolver.TargetOperation, + ChainKey: resolver.ChainKeyFor(f.apiID, f.vhost, operation), + KnownToProtocol: f.knownToProtocol, + } +} + +func (f *fakeOperationResolver) Resolve(_ context.Context, view resolver.RequestView) (resolver.Resolution, error) { + f.identified++ + f.seenView = view + f.seenBody = view.Body + + if f.forcedErr != nil { + return resolver.Resolution{}, f.forcedErr + } + + if f.header != "" { + values := view.Headers[f.header] + if len(values) == 0 { + return resolver.Resolution{}, &resolver.ResolutionError{Kind: resolver.FailureInvalidRequest} + } + return f.resolveOperation(values[0]), nil + } + + var envelope map[string]any + if err := json.Unmarshal(view.Body, &envelope); err != nil { + return resolver.Resolution{}, &resolver.ResolutionError{Kind: resolver.FailureParse, Cause: err} + } + op, ok := envelope[f.bodyField].(string) + if !ok { + return resolver.Resolution{}, &resolver.ResolutionError{Kind: resolver.FailureInvalidRequest} + } + return f.resolveOperation(op), nil +} + +// headerPolicy runs at the request-header phase, so the deferred path can prove +// header policies really execute at the body callback. +type headerPolicy struct { + setHeader string + setValue string + statusCode int // when non-zero, short-circuits with this status + ran *bool +} + +func (p *headerPolicy) Mode() policy.ProcessingMode { + return policy.ProcessingMode{RequestHeaderMode: policy.HeaderModeProcess} +} + +func (p *headerPolicy) OnRequestHeaders(_ context.Context, _ *policy.RequestHeaderContext, _ map[string]interface{}) policy.RequestHeaderAction { + if p.ran != nil { + *p.ran = true + } + if p.statusCode != 0 { + return policy.ImmediateResponse{ + StatusCode: p.statusCode, + Headers: map[string]string{"www-authenticate": "Bearer"}, + Body: []byte(`{"error":"unauthorized"}`), + } + } + return policy.UpstreamRequestHeaderModifications{ + HeadersToSet: map[string]string{p.setHeader: p.setValue}, + } +} + +// bodyPolicy runs at the request-body phase and records the bytes it was given, so a +// test can prove the decoded body is reused rather than decompressed twice. +type bodyPolicy struct { + seen *[]byte +} + +func (p *bodyPolicy) Mode() policy.ProcessingMode { + return policy.ProcessingMode{RequestBodyMode: policy.BodyModeBuffer} +} + +func (p *bodyPolicy) OnRequestBody(_ context.Context, ctx *policy.RequestContext, _ map[string]interface{}) policy.RequestAction { + if p.seen != nil && ctx.Body != nil { + *p.seen = ctx.Body.Content + } + return nil +} + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +type resolutionFixture struct { + server *ExternalProcessorServer + kernel *Kernel + resolvers resolver.ResolverRegistry + t *testing.T +} + +func newResolutionFixture(t *testing.T, resolvers ...resolver.Resolver) *resolutionFixture { + t.Helper() + reg := resolver.NewRegistry() + for _, r := range resolvers { + require.NoError(t, reg.Register(r)) + } + // The identity resolver is always available, exactly as it is in production: a route + // with no resolver_name normalises to it. + if _, taken := reg.Get(resolver.RouteKeyResolverName); !taken { + require.NoError(t, reg.Register(&resolver.RouteKeyResolver{})) + } + reg.Freeze() + + k := NewKernel() + return &resolutionFixture{ + server: NewExternalProcessorServer(k, newTestExecutor(), config.TracingConfig{}, "", + testMaxDecompressedBytes, testMaxDecompressedBytes), + kernel: k, + resolvers: reg, + t: t, + } +} + +// route registers a single RouteConfig, preparing its resolver the way xDS ingest does. +// The resolution fields live on the embedded resolver.RouteResolution, so they are +// passed as one value; the returned pointer is the stored one, so a test can adjust a +// non-resolution field (a buffer limit) after. +func (f *resolutionFixture) route(routeKey string, rr resolver.RouteResolution) *RouteConfig { + f.t.Helper() + rc := f.unpreparedRoute(routeKey, rr) + require.NoError(f.t, PrepareRoute(f.resolvers, routeKey, rc)) + + f.kernel.ApplyWholeRouteConfigs(map[string]*RouteConfig{routeKey: rc}) + return rc +} + +// unpreparedRoute builds the RouteConfig without preparing it, for the tests that need +// a route the kernel would refuse. Ingest never produces one; a non-xDS load path could. +func (f *resolutionFixture) unpreparedRoute(routeKey string, rr resolver.RouteResolution) *RouteConfig { + rr.RouteKey = routeKey + // APIId and Vhost are the partition a prepared resolver captures, so a route whose + // resolver composes operation keys needs them to compose anything at all. + return &RouteConfig{ + Metadata: RouteMetadata{ + RouteName: routeKey, + APIId: testAPIID, + Vhost: testVhost, + }, + RouteResolution: rr, + } +} + +// Composition inputs shared by every fixture in this file, so a composed key in an +// assertion is spelled the same way the kernel will compose it. +const ( + testAPIID = "api-1" + testVhost = "example.com" +) + +// operationChainKey is what the engine composes for an operation on a fixture route. +func operationChainKey(operation string) string { + return resolver.ChainKeyFor(testAPIID, testVhost, operation) +} + +// operationChain registers a chain under an operation's *composed* key, which is where +// a resolver-bearing route's requests will look for it. +func (f *resolutionFixture) operationChain(operation string, policies ...policy.Policy) *registry.PolicyChain { + return f.chain(operationChainKey(operation), policies...) +} + +func (f *resolutionFixture) chain(key string, policies ...policy.Policy) *registry.PolicyChain { + chain := buildChainFor(policies) + f.kernel.RegisterRoute(key, chain) + return chain +} + +// buildChainFor derives the chain's phase requirements from its policies' declared +// modes, mirroring what the xDS handler does when it builds a chain. +func buildChainFor(policies []policy.Policy) *registry.PolicyChain { + chain := ®istry.PolicyChain{Policies: policies} + for _, p := range policies { + chain.PolicySpecs = append(chain.PolicySpecs, policy.PolicySpec{Name: "fake", Version: "v1", Enabled: true}) + mode := p.Mode() + if mode.RequestHeaderMode == policy.HeaderModeProcess { + chain.RequiresRequestHeader = true + } + if mode.RequestBodyMode == policy.BodyModeBuffer { + chain.RequiresRequestBody = true + } + if mode.ResponseBodyMode == policy.BodyModeBuffer { + chain.RequiresResponseBody = true + } + } + return chain +} + +func headersRequest(routeKey string, endOfStream bool, headers map[string]string) *extprocv3.ProcessingRequest { + values := make([]*corev3.HeaderValue, 0, len(headers)) + for k, v := range headers { + values = append(values, &corev3.HeaderValue{Key: k, RawValue: []byte(v)}) + } + return &extprocv3.ProcessingRequest{ + Request: &extprocv3.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extprocv3.HttpHeaders{ + Headers: &corev3.HeaderMap{Headers: values}, + EndOfStream: endOfStream, + }, + }, + Attributes: map[string]*structpb.Struct{ + constants.ExtProcFilter: { + Fields: map[string]*structpb.Value{ + "xds.route_name": structpb.NewStringValue(routeKey), + }, + }, + }, + } +} + +func gzipBytes(t *testing.T, in []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, err := w.Write(in) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +// ─── Identity routes (invariant 5.1 / 5.4) ─────────────────────────────────── + +// An identity route's whole resolution happened at ingest, so the request path must not +// build a request view, buffer a body, acquire a renderer, or call Resolve. +func TestIdentityRoute_DoesNoRequestTimeResolverWork(t *testing.T) { + f := newResolutionFixture(t) + rc := f.route("GET|/pets|example.com", resolver.RouteResolution{ResolverName: resolver.RouteKeyResolverName}) + f.chain("GET|/pets|example.com", &testutils.NoopPolicy{}) + + require.True(t, rc.Prepared.IsStatic(), "route-key must resolve entirely at ingest") + assert.False(t, rc.Prepared.Requirements.BuffersBody()) + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("GET|/pets|example.com", true, map[string]string{":method": "GET", ":path": "/pets"}), &execCtx) + + assert.Equal(t, bindReady, outcome) + assert.Nil(t, denial) + require.NotNil(t, execCtx) + assert.Nil(t, execCtx.pending) + assert.Empty(t, execCtx.operation, "a direct route has no operation to report") +} + +// A statically-prepared resolver cannot name a chain other than its own route's — and +// that is settled at ingest, not per request: PrepareRoute refuses the route, so it never +// reaches the kernel and no request to it can bind the other route's chain. +// +// Checking it here rather than only in the resolver package covers the wrapper both +// ingest and these fixtures go through, which is where the route's partition and +// effective key are assembled. +func TestPrepareRoute_RefusesAStaticResolutionNamingAnotherRoutesChain(t *testing.T) { + factory := &staticKeyResolver{name: "bad-static", key: "GET|/admin|example.com"} + f := newResolutionFixture(t, factory) + f.chain("GET|/admin|example.com", &testutils.NoopPolicy{}) + + routeKey := "GET|/pets|example.com" + rc := f.unpreparedRoute(routeKey, resolver.RouteResolution{ResolverName: "bad-static"}) + + err := PrepareRoute(f.resolvers, routeKey, rc) + require.Error(t, err, "the route must be refused at ingest, not fail on every request") + assert.Contains(t, err.Error(), "invalid static resolution") + assert.Nil(t, rc.Prepared, "a refused route stores no prepared resolver") +} + +// staticKeyResolver prepares a static direct resolution naming whatever key it was given, +// so a test can drive PrepareRoute's validation of that resolution. +type staticKeyResolver struct { + name string + key string +} + +func (r *staticKeyResolver) Name() string { return r.name } + +func (r *staticKeyResolver) Prepare(resolver.ResolverRouteConfig) (resolver.PreparedResolver, error) { + return r, nil +} + +func (r *staticKeyResolver) Requirements() resolver.RequestRequirements { + return resolver.RequestRequirements{} +} + +func (r *staticKeyResolver) Resolve(context.Context, resolver.RequestView) (resolver.Resolution, error) { + return r.StaticResolution(), nil +} + +func (r *staticKeyResolver) StaticResolution() resolver.Resolution { + return resolver.Resolution{Target: resolver.TargetDirectRoute, ChainKey: r.key} +} + +// Invariant 5.1: an empty resolver_name and the explicit identity name behave the +// same, and both read the chain key from canonical_chain_key. +func TestIdentityRoute_UsesCanonicalChainKey(t *testing.T) { + for _, name := range []string{"", resolver.RouteKeyResolverName} { + t.Run(fmt.Sprintf("resolver_name=%q", name), func(t *testing.T) { + f := newResolutionFixture(t) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: name, + CanonicalChainKey: "canonical-chain", + }) + f.chain("canonical-chain", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, _ := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", true, map[string]string{":method": "POST"}), &execCtx) + + require.Equal(t, bindReady, outcome) + assert.Equal(t, "canonical-chain", execCtx.chainKey) + }) + } +} + +// Invariant 5.4: an identity route with no chain keeps the pre-existing sterile 500, +// and must not be diverted into the new resolution-failure renderer. +func TestIdentityRoute_MissingChainStillYieldsNoChainOutcome(t *testing.T) { + f := newResolutionFixture(t) + f.route("GET|/pets|example.com", resolver.RouteResolution{}) + + var execCtx *PolicyExecutionContext + rm, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("GET|/pets|example.com", true, map[string]string{":method": "GET"}), &execCtx) + + assert.Equal(t, bindNoChain, outcome, "must take the pre-existing missing-chain path, not a resolution failure") + assert.Nil(t, denial) + assert.Nil(t, execCtx) + assert.Equal(t, "GET|/pets|example.com", rm.RouteName) +} + +// A RouteConfig that reached the kernel without canonical_chain_key (older +// controller, non-xDS load path) still resolves: for an identity route the route key +// *is* the chain key. +func TestIdentityRoute_AbsentCanonicalKeyFallsBackToRouteKey(t *testing.T) { + f := newResolutionFixture(t) + // No CanonicalChainKey on the wire: the fixture applies the same one-time fallback + // ingest does, and the prepared resolver reads the effective value from there. + f.route("GET|/pets|example.com", resolver.RouteResolution{}) + f.chain("GET|/pets|example.com", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, _ := f.server.initializeExecutionContext(context.Background(), + headersRequest("GET|/pets|example.com", true, map[string]string{":method": "GET"}), &execCtx) + + require.Equal(t, bindReady, outcome) + assert.Equal(t, "GET|/pets|example.com", execCtx.chainKey) +} + +// ─── Header-only resolution ────────────────────────────────────────────────── + +func TestHeaderOnlyResolver_ResolvesAtHeaderPhase(t *testing.T) { + r := &fakeOperationResolver{name: "hdr", reqs: resolver.RequestRequirements{Headers: true}, header: "x-op"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "hdr", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", true, map[string]string{ + ":method": "post", ":path": "/rpc", "x-op": "SendMessage", + }), &execCtx) + + require.Equal(t, bindReady, outcome) + assert.Nil(t, denial) + require.NotNil(t, execCtx) + assert.Equal(t, operationChainKey("SendMessage"), execCtx.chainKey) + assert.Nil(t, execCtx.pending, "a header-only resolver must not defer") + + // GO-AUTH-006: the method reaches the resolver upper-cased, so no downstream + // comparison or map key can miss on case. + assert.Equal(t, "POST", r.seenView.Method) + assert.Equal(t, "/rpc", r.seenView.Path) +} + +func TestHeaderOnlyResolver_UnknownOperationDenies(t *testing.T) { + r := &fakeOperationResolver{name: "hdr", reqs: resolver.RequestRequirements{Headers: true}, header: "x-op"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "hdr", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", true, map[string]string{":method": "POST", "x-op": "NoSuchOp"}), &execCtx) + + require.Equal(t, bindFailed, outcome) + require.NotNil(t, denial) + assert.Equal(t, resolver.FailureUnknownOperation, denial.failure.Kind) + assert.Nil(t, execCtx, "a denied request must leave no execution context") +} + +// A route naming a resolver this binary lacks is dropped at ingest, so it never reaches +// the kernel at all. +func TestUnknownResolver_IsRefusedAtPreparation(t *testing.T) { + f := newResolutionFixture(t) + _, err := resolver.PrepareRoute(f.resolvers, resolver.ResolverRouteConfig{ + RouteKey: "POST|/rpc|example.com", + CanonicalChainKey: "route-level-chain", + ResolverName: "not-registered", + }) + var re *resolver.ResolutionError + require.ErrorAs(t, err, &re) + assert.Equal(t, resolver.FailureUnknownResolver, re.Kind) +} + +// If such a route reaches the kernel anyway — a non-xDS load path — it must deny, never +// quietly resolve by route key, which would apply the route-level chain to every +// operation the route multiplexes. +func TestUnpreparedRoute_DeniesWithoutFallingBackToTheRouteKey(t *testing.T) { + f := newResolutionFixture(t) + routeKey := "POST|/rpc|example.com" + rc := f.unpreparedRoute(routeKey, resolver.RouteResolution{ + ResolverName: "not-registered", + CanonicalChainKey: "route-level-chain", + }) + f.kernel.ApplyWholeRouteConfigs(map[string]*RouteConfig{routeKey: rc}) + routeLevel := f.chain("route-level-chain", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest(routeKey, true, map[string]string{":method": "POST"}), &execCtx) + + require.Equal(t, bindFailed, outcome) + require.NotNil(t, denial) + assert.Equal(t, resolver.FailureUnknownResolver, denial.failure.Kind) + assert.Nil(t, execCtx) + assert.NotNil(t, routeLevel, "the route-level chain exists but must not have been selected") +} + +// Resolution succeeded but the chain is absent — a configuration or xDS-skew +// failure, not the protocol's "unknown operation". +func TestResolvedButMissingChain_IsAConfigurationFailure(t *testing.T) { + // A closed operation set: the protocol says SendMessage exists, so no chain for it + // means the controller built the deployment wrong. + r := &fakeOperationResolver{ + name: "hdr", reqs: resolver.RequestRequirements{Headers: true}, header: "x-op", + knownToProtocol: true, + } + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "hdr", + }) + // The operation's chain is deliberately not registered. + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", true, map[string]string{":method": "POST", "x-op": "SendMessage"}), &execCtx) + + require.Equal(t, bindFailed, outcome) + assert.Equal(t, resolver.FailureChainMissing, denial.failure.Kind, + "a missing chain is a deployment fault, distinct from the client naming something unknown") +} + +// The other side of the same branch: an *open* operation set, where no chain for the +// identified operation means the client named something that does not exist. This is the +// distinction the controller-supplied operation map used to provide, now answered from +// the protocol definition — and it decides whether the caller or the deployment is at +// fault, so the two must not collapse into one failure kind. +func TestResolvedButMissingChain_OpenOperationSetBlamesTheClient(t *testing.T) { + r := &fakeOperationResolver{name: "hdr", reqs: resolver.RequestRequirements{Headers: true}, header: "x-op"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ResolverName: "hdr"}) + f.operationChain("tools/call", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", true, + map[string]string{":method": "POST", "x-op": "tools/call:unlisted"}), &execCtx) + + require.Equal(t, bindFailed, outcome) + require.NotNil(t, denial) + assert.Equal(t, resolver.FailureUnknownOperation, denial.failure.Kind, + "the client named something that does not exist, distinct from a missing chain") +} + +// ─── Deferred (body-phase) binding ─────────────────────────────────────────── + +func TestBodyResolver_DefersAndAsksEnvoyToBuffer(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, _ := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", false, map[string]string{":method": "POST", ":path": "/rpc"}), &execCtx) + + require.Equal(t, bindPending, outcome) + require.NotNil(t, execCtx) + assert.Nil(t, execCtx.policyChain, "no chain may be selected before the body arrives") + assert.Zero(t, r.identified, "the resolver must not run before it has the body") + + resp := pendingResolutionResponse(execCtx.pending.prepared.Requirements) + assert.Equal(t, extprocconfigv3.ProcessingMode_BUFFERED, resp.ModeOverride.RequestBodyMode) + assert.Equal(t, extprocconfigv3.ProcessingMode_SEND, resp.ModeOverride.ResponseHeaderMode, + "the response-header callback is where the resolved chain's response mode is returned") + assert.NotNil(t, resp.GetRequestHeaders()) + assert.Nil(t, resp.GetRequestHeaders().Response, "no mutation may be emitted before any policy has run") +} + +// Envoy sends no request-body callback when the request headers are end-of-stream, +// so a pending request would wait forever. Resolve or deny during the header +// callback instead. +func TestBodyResolver_HeaderEndOfStreamResolvesImmediately(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest("POST|/rpc|example.com", true, map[string]string{":method": "POST"}), &execCtx) + + require.Equal(t, bindFailed, outcome, "an empty body cannot carry an operation") + assert.Equal(t, resolver.FailureParse, denial.failure.Kind) + assert.Equal(t, 1, r.identified, "the resolver must run at the header phase rather than wait") + assert.Nil(t, execCtx) + + // The contract a BodyBuffered resolver is held to here: it is handed a view with no + // body and must classify that rather than assume a non-empty slice. See + // resolver.PreparedResolver.Resolve. + assert.Nil(t, r.seenBody, "a bodyless request reaches Resolve with Body nil") +} + +func TestDeferredBinding_RunsHeaderThenBodyPoliciesAtBodyPhase(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + + headerRan := false + var bodySeen []byte + f.operationChain("SendMessage", + &headerPolicy{setHeader: "x-op", setValue: "SendMessage", ran: &headerRan}, + &bodyPolicy{seen: &bodySeen}, + ) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + require.False(t, headerRan, "header policies must not run before the chain is known") + + body := []byte(`{"method":"SendMessage"}`) + resp, err := execCtx.processRequestBody(context.Background(), &extprocv3.HttpBody{Body: body, EndOfStream: true}) + require.NoError(t, err) + + assert.Equal(t, operationChainKey("SendMessage"), execCtx.chainKey) + assert.Nil(t, execCtx.pending, "the chain is bound, so nothing stays pending") + assert.True(t, execCtx.boundAtBodyPhase) + assert.True(t, headerRan, "the resolved chain's header policies run at the body callback") + assert.Equal(t, body, bodySeen, "body policies see the same decoded bytes the resolver did") + + // Consequence 1 of deferred binding: header mutations are emitted on the + // body-phase response, not the header-phase one. + bodyResp := resp.GetRequestBody() + require.NotNil(t, bodyResp, "the response must be body-phase shaped") + require.NotNil(t, bodyResp.Response.HeaderMutation) + var setKeys []string + for _, h := range bodyResp.Response.HeaderMutation.SetHeaders { + setKeys = append(setKeys, h.Header.Key) + } + assert.Contains(t, setKeys, "x-op") + + // Envoy ignores a ModeOverride on a body callback, so none is sent: the response + // modes are decided at the response-header callback instead. + assert.Nil(t, resp.ModeOverride) +} + +// A policy rejection raised by the deferred chain's header policies still becomes an +// ImmediateResponse, even though those policies ran at the body callback. +func TestDeferredBinding_HeaderPolicyShortCircuitAtBodyPhase(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + var bodySeen []byte + f.operationChain("SendMessage", &headerPolicy{statusCode: 401}, &bodyPolicy{seen: &bodySeen}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + imm := resp.GetImmediateResponse() + require.NotNil(t, imm) + assert.Equal(t, typev3.StatusCode(401), imm.Status.Code) + assert.Nil(t, bodySeen, "body policies must not run after a header policy short-circuits") +} + +func TestDeferredBinding_ResolutionFailureDenies(t *testing.T) { + tests := []struct { + name string + body []byte + wantStatus typev3.StatusCode + wantKind resolver.FailureKind + }{ + {"malformed payload", []byte(`not json`), typev3.StatusCode_BadRequest, resolver.FailureParse}, + {"valid payload, invalid envelope", []byte(`{"jsonrpc":"2.0"}`), typev3.StatusCode_BadRequest, resolver.FailureInvalidRequest}, + {"unknown operation", []byte(`{"method":"NoSuchOp"}`), typev3.StatusCode_NotFound, resolver.FailureUnknownOperation}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: tt.body, EndOfStream: true}) + require.NoError(t, err) + + imm := resp.GetImmediateResponse() + require.NotNil(t, imm) + assert.Equal(t, tt.wantStatus, imm.Status.Code) + assert.Nil(t, execCtx.policyChain, "a denied request must never bind a chain") + assert.True(t, execCtx.resolutionDenied) + + // error-handling.md: sterile body, no internals, correlation id only. + assert.NotContains(t, string(imm.Body), "body") + assert.Regexp(t, `^\{"error":"[A-Za-z ]+","error_id":"[0-9a-f-]{36}"\}$`, string(imm.Body)) + }) + } +} + +// ─── Body ceilings on the deferred path ─────────────────────────────────────── + +func TestDeferredBinding_WireLimitRejectsBeforeResolving(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + rc := f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + rc.MaxRequestBodyBytes = 8 + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + imm := resp.GetImmediateResponse() + require.NotNil(t, imm) + assert.Equal(t, typev3.StatusCode_PayloadTooLarge, imm.Status.Code) + assert.Zero(t, r.identified, "an over-limit body must be rejected before the resolver sees it") + // The configured limit is never echoed back (file-access.md directive 5): the body + // is exactly the sterile reason phrase plus a correlation id. + assert.Regexp(t, `^\{"error":"Payload Too Large","error_id":"[0-9a-f-]{36}"\}$`, string(imm.Body)) +} + +func TestDeferredBinding_DefaultWireLimitApplies(t *testing.T) { + rc := &RouteConfig{} + assert.Equal(t, DefaultMaxResolverRequestBodyBytes, rc.EffectiveMaxRequestBodyBytes(), + "a body-resolved route with no explicit limit must still be bounded") + + rc.MaxRequestBodyBytes = 1024 + assert.Equal(t, int64(1024), rc.EffectiveMaxRequestBodyBytes()) + + // A nonsensical value falls back to the default rather than disabling the bound. + rc.MaxRequestBodyBytes = -1 + assert.Equal(t, DefaultMaxResolverRequestBodyBytes, rc.EffectiveMaxRequestBodyBytes()) +} + +func TestDeferredBinding_ResolvesFromDecodedGzipBody(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + var bodySeen []byte + f.operationChain("SendMessage", &bodyPolicy{seen: &bodySeen}) + + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", map[string]string{ + ":method": "POST", ":path": "/rpc", "content-encoding": "gzip", + }) + + plain := []byte(`{"method":"SendMessage"}`) + _, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: gzipBytes(t, plain), EndOfStream: true}) + require.NoError(t, err) + + assert.Equal(t, operationChainKey("SendMessage"), execCtx.chainKey) + assert.Equal(t, plain, r.seenBody, "the resolver must see decoded bytes, never the compressed frame") + assert.Equal(t, plain, bodySeen, "the decoded body is reused, not decompressed a second time") +} + +// A body that claims a supported coding but does not decode under it must fail closed: +// passing the raw bytes on would resolve to whatever they happen to look like. +func TestDeferredBinding_UndecodableBodyFailsClosed(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", map[string]string{ + ":method": "POST", "content-encoding": "gzip", + }) + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) // not actually gzip + require.NoError(t, err) + + imm := resp.GetImmediateResponse() + require.NotNil(t, imm) + assert.Equal(t, typev3.StatusCode_BadRequest, imm.Status.Code, + "a body that will not decode is the client's problem, not an engine fault") + assert.Zero(t, r.identified, "raw compressed bytes must never reach the resolver") +} + +// A coding the engine cannot decode must be rejected before the resolver runs. +// decompressBody returns an unrecognised coding's bytes unchanged and reports no +// error — lenient behaviour that is right for a policy and wrong for a resolver, +// which would then select a chain from bytes nobody decoded. +func TestDeferredBinding_UndecodableEncodingsRejectedBeforeResolving(t *testing.T) { + tests := []struct { + name string + headers map[string]string + }{ + {"unknown coding", map[string]string{"content-encoding": "exotic-codec"}}, + {"stacked codings in one header", map[string]string{"content-encoding": "gzip, br"}}, + {"stacked identical codings", map[string]string{"content-encoding": "gzip, gzip"}}, + {"coding with parameters", map[string]string{"content-encoding": "gzip;q=1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + headers := map[string]string{":method": "POST", ":path": "/rpc"} + for k, v := range tt.headers { + headers[k] = v + } + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", headers) + + // A body that would resolve perfectly well if it were read raw, so the only + // thing stopping it is the encoding gate. + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + imm := resp.GetImmediateResponse() + require.NotNil(t, imm) + assert.Equal(t, typev3.StatusCode_UnsupportedMediaType, imm.Status.Code) + assert.Zero(t, r.identified, "the resolver must not see a body the engine did not decode") + assert.Nil(t, execCtx.policyChain, "no chain may be bound from undecoded bytes") + }) + } +} + +// Content codings are case-insensitive, and a coding list may be split across header +// lines. Both were previously invisible to the exact-match switch: `GZIP` fell through +// as "unrecognised" and reached the resolver as a raw gzip frame, and only the last +// header line was ever examined. +func TestDeferredBinding_ContentCodingHeaderNormalization(t *testing.T) { + t.Run("uppercase coding decodes", func(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", map[string]string{ + ":method": "POST", "content-encoding": "GZIP", + }) + plain := []byte(`{"method":"SendMessage"}`) + _, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: gzipBytes(t, plain), EndOfStream: true}) + require.NoError(t, err) + + assert.Equal(t, operationChainKey("SendMessage"), execCtx.chainKey) + assert.Equal(t, plain, r.seenBody) + assert.Equal(t, "gzip", execCtx.requestContentEncoding, + "the canonical token is pinned so a mutated body re-encodes correctly") + }) + + t.Run("identity means no coding", func(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", map[string]string{ + ":method": "POST", "content-encoding": "identity", + }) + plain := []byte(`{"method":"SendMessage"}`) + _, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: plain, EndOfStream: true}) + require.NoError(t, err) + + assert.Equal(t, operationChainKey("SendMessage"), execCtx.chainKey) + assert.Equal(t, plain, r.seenBody) + }) +} + +// The unit-level table for the header reduction, including the split-across-lines case +// a single ProcessingRequest header map can express but the fixture cannot. +func TestResolverContentCoding(t *testing.T) { + tests := []struct { + name string + headers map[string][]string + want string + wantErr string + }{ + {"absent", map[string][]string{}, "", ""}, + {"empty value", map[string][]string{"content-encoding": {""}}, "", ""}, + {"identity", map[string][]string{"content-encoding": {"identity"}}, "", ""}, + {"gzip", map[string][]string{"content-encoding": {"gzip"}}, "gzip", ""}, + {"br", map[string][]string{"content-encoding": {"br"}}, "br", ""}, + {"padded and mixed case", map[string][]string{"content-encoding": {" GZip "}}, "gzip", ""}, + {"header name case", map[string][]string{"Content-Encoding": {"gzip"}}, "gzip", ""}, + {"identity alongside a real coding", map[string][]string{"content-encoding": {"identity, gzip"}}, "gzip", ""}, + {"unknown", map[string][]string{"content-encoding": {"exotic"}}, "", `"exotic" cannot be decoded`}, + {"stacked in one value", map[string][]string{"content-encoding": {"gzip, br"}}, "", "stacked content codings"}, + { + // Only the last line reaches ec.requestContentEncoding, so reading the + // captured value alone would silently accept this as plain "br". + name: "stacked across header lines", + headers: map[string][]string{"content-encoding": {"gzip", "br"}}, + wantErr: "stacked content codings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolverContentCoding(tt.headers) + if tt.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Empty(t, got) + }) + } +} + +// Invariant: the gate is deferred-path only. An identity route keeps decompressBody's +// lenient behaviour — a body it cannot decode still reaches policies as raw bytes with +// the encoding cleared — because there the chain is already selected and no +// policy-selection decision hangs on how the body reads. +func TestIdentityRoute_UndecodableBodyKeepsLenientBehaviour(t *testing.T) { + f := newResolutionFixture(t) + var seen []byte + chain := buildChainFor([]policy.Policy{&bodyPolicy{seen: &seen}}) + + ec := newPolicyExecutionContext(f.server, "POST|/pets|example.com", chain) + ec.buildRequestContexts(&extprocv3.HttpHeaders{ + Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{ + {Key: ":method", RawValue: []byte("POST")}, + {Key: "content-encoding", RawValue: []byte("gzip")}, + }}, + }, RouteMetadata{RouteName: "POST|/pets|example.com"}) + + notGzip := []byte(`{"plain":true}`) + resp, err := ec.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: notGzip, EndOfStream: true}) + require.NoError(t, err) + + assert.Nil(t, resp.GetImmediateResponse(), "an identity route must not start rejecting these") + assert.Equal(t, notGzip, seen, "policies still receive the raw bytes, as before") + assert.Empty(t, ec.requestContentEncoding, "the encoding is cleared so nothing tries to re-compress") +} + +// The decoded ceiling applies to an uncompressed body too, so the resolver's input +// is bounded by the same number either way. +func TestDeferredBinding_DecodedLimitAppliesToUncompressedBody(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + reg := resolver.NewRegistry() + require.NoError(t, reg.Register(r)) + reg.Freeze() + + k := NewKernel() + // A decoded ceiling well below the wire ceiling, so only the decoded check fires. + server := NewExternalProcessorServer(k, newTestExecutor(), config.TracingConfig{}, "", 8, + testMaxDecompressedBytes) + f := &resolutionFixture{server: server, kernel: k, resolvers: reg, t: t} + rc := f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + // A wire limit far above the decoded ceiling, so only the decoded check can fire. + rc.MaxRequestBodyBytes = 1 << 20 + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + imm := resp.GetImmediateResponse() + require.NotNil(t, imm) + assert.Equal(t, typev3.StatusCode_PayloadTooLarge, imm.Status.Code) + assert.Zero(t, r.identified) +} + +// ─── Response phases after a denial ────────────────────────────────────────── + +// Envoy should not send response callbacks after an ImmediateResponse, but if one +// arrives it must not dereference the nil chain. +func TestDeniedRequest_ResponsePhasesDoNotDereferenceNilChain(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + _, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`bad`), EndOfStream: true}) + require.NoError(t, err) + require.Nil(t, execCtx.policyChain) + + respHeaders, err := execCtx.processResponseHeaders(context.Background(), &extprocv3.HttpHeaders{ + Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":status", RawValue: []byte("200")}}}, + }) + require.NoError(t, err) + assert.NotNil(t, respHeaders.GetResponseHeaders()) + + respBody, err := execCtx.processResponseBody(context.Background(), &extprocv3.HttpBody{Body: []byte("x"), EndOfStream: true}) + require.NoError(t, err) + assert.NotNil(t, respBody.GetResponseBody()) + + // A second request-body callback for the same denied request is also inert. + again, err := execCtx.processRequestBody(context.Background(), &extprocv3.HttpBody{EndOfStream: true}) + require.NoError(t, err) + assert.NotNil(t, again.GetRequestBody()) +} + +// ─── Mode overrides ────────────────────────────────────────────────────────── + +func TestPendingModeOverride(t *testing.T) { + buffered := pendingModeOverride(resolver.RequestRequirements{Body: resolver.BodyBuffered}) + assert.Equal(t, extprocconfigv3.ProcessingMode_BUFFERED, buffered.RequestBodyMode) + assert.Equal(t, extprocconfigv3.ProcessingMode_NONE, buffered.ResponseBodyMode) + assert.Equal(t, extprocconfigv3.ProcessingMode_SKIP, buffered.RequestTrailerMode) + assert.Equal(t, extprocconfigv3.ProcessingMode_SKIP, buffered.ResponseTrailerMode) + + headerOnly := pendingModeOverride(resolver.RequestRequirements{Headers: true}) + assert.Equal(t, extprocconfigv3.ProcessingMode_NONE, headerOnly.RequestBodyMode) +} + +// Invariant 5.3: an identity route's mode override is unchanged — chain-derived, and +// never routed through the pending path. +func TestIdentityRoute_ModeOverrideIsChainDerived(t *testing.T) { + f := newResolutionFixture(t) + chain := ®istry.PolicyChain{RequiresRequestBody: true, RequiresResponseBody: true} + ec := newPolicyExecutionContext(f.server, "GET|/pets|example.com", chain) + ec.phase = phaseRequestHeaders + + mode := ec.getModeOverride() + require.NotNil(t, mode) + assert.Equal(t, extprocconfigv3.ProcessingMode_BUFFERED, mode.RequestBodyMode) + assert.Equal(t, extprocconfigv3.ProcessingMode_BUFFERED, mode.ResponseBodyMode) +} + +// The response-header callback is where a body-phase-bound chain's response-body +// mode reaches Envoy — the one callback that still precedes Envoy's decision about +// how to deliver the upstream body, and one it honours overrides on. +func TestBodyPhaseBoundChain_ResponseModeComesFromResponseHeaderCallback(t *testing.T) { + f := newResolutionFixture(t) + chain := ®istry.PolicyChain{RequiresResponseBody: true} + ec := newPolicyExecutionContext(f.server, "POST|/rpc|example.com", chain) + ec.boundAtBodyPhase = true + + ec.phase = phaseRequestBody + assert.Nil(t, ec.getModeOverride(), "Envoy ignores a ModeOverride on a body callback; none must be sent") + + ec.phase = phaseResponseHeaders + ec.responseHeaderCtx = &policy.ResponseHeaderContext{ + ResponseStatus: 200, + ResponseHeaders: policy.NewHeaders(map[string][]string{}), + } + ec.requestHeaderCtx = &policy.RequestHeaderContext{Method: "POST"} + mode := ec.getModeOverride() + require.NotNil(t, mode) + assert.Equal(t, extprocconfigv3.ProcessingMode_BUFFERED, mode.ResponseBodyMode) +} + +// ─── Generic failure rendering ─────────────────────────────────────────────── + +func TestGenericResolutionFailure_StatusPerKind(t *testing.T) { + tests := map[resolver.FailureKind]int{ + resolver.FailureParse: 400, + resolver.FailureInvalidRequest: 400, + resolver.FailureMultiOperation: 400, + resolver.FailureUnknownOperation: 404, + resolver.FailurePayloadTooLarge: 413, + resolver.FailureUnsupportedEncoding: 415, + resolver.FailureUndecodableBody: 400, + resolver.FailureUnknownResolver: 500, + resolver.FailureChainMissing: 500, + resolver.FailureInternal: 500, + } + for kind, want := range tests { + t.Run(string(kind), func(t *testing.T) { + out := genericResolutionFailure(kind, "abc-123") + assert.Equal(t, want, out.StatusCode) + assert.Equal(t, "application/json", out.Headers["content-type"]) + assert.Equal(t, "abc-123", out.Headers["x-error-id"]) + // The kind itself is an internal classification and never shipped. + assert.NotContains(t, string(out.Body), string(kind)) + }) + } +} + +// A renderer that returns no status has declined; the generic response is used +// rather than emitting an HTTP 0. +// ─── Request view construction ─────────────────────────────────────────────── + +func TestBuildRequestView(t *testing.T) { + view := buildRequestView("POST|/rpc|example.com", &extprocv3.HttpHeaders{ + Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{ + {Key: ":method", RawValue: []byte("post")}, + {Key: ":path", RawValue: []byte("/rpc?x=1")}, + {Key: "accept", RawValue: []byte("application/json")}, + {Key: "accept", RawValue: []byte("text/plain")}, + }}, + }) + + assert.Equal(t, "POST|/rpc|example.com", view.RouteKey) + assert.Equal(t, "POST", view.Method, "the method must be upper-cased at extraction (GO-AUTH-006)") + assert.Equal(t, "/rpc?x=1", view.Path) + assert.Equal(t, []string{"application/json", "text/plain"}, view.Headers["accept"]) + assert.Nil(t, view.Body, "the body is attached only once it has been decoded") +} + +func TestBuildRequestView_NilHeaders(t *testing.T) { + view := buildRequestView("r", nil) + assert.Equal(t, "r", view.RouteKey) + assert.Nil(t, view.Headers) +} + +// The resolver must observe the retained header-phase view at the body callback, not +// values re-derived there. +func TestDeferredBinding_ResolverSeesRetainedHeaderView(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", map[string]string{ + ":method": "post", ":path": "/rpc?trace=1", "x-client": "cli", + }) + _, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + assert.Equal(t, "POST", r.seenView.Method) + assert.Equal(t, "/rpc?trace=1", r.seenView.Path) + assert.Equal(t, []string{"cli"}, r.seenView.Headers["x-client"]) +} + +// ─── Fixture helpers ───────────────────────────────────────────────────────── + +func (f *resolutionFixture) bindPending(t *testing.T, routeKey string) *PolicyExecutionContext { + t.Helper() + return f.bindPendingWithHeaders(t, routeKey, map[string]string{":method": "POST", ":path": "/rpc"}) +} + +func (f *resolutionFixture) bindPendingWithHeaders(t *testing.T, routeKey string, headers map[string]string) *PolicyExecutionContext { + t.Helper() + var execCtx *PolicyExecutionContext + _, outcome, denial := f.server.initializeExecutionContext(context.Background(), + headersRequest(routeKey, false, headers), &execCtx) + require.Equal(t, bindPending, outcome, "expected deferred binding, got denial %v", denial) + require.NotNil(t, execCtx) + return execCtx +} + +// Guard against a stray "strings" import removal breaking the method-normalization +// assertion above: buildRequestView must actually be doing the upper-casing. +var _ = strings.ToUpper + +// ─── Compressed-body mutation on the deferred path ─────────────────────────── + +// mutatingBodyPolicy rewrites the request body, so the recompression path is exercised. +type mutatingBodyPolicy struct { + replacement []byte + seen *[]byte +} + +func (p *mutatingBodyPolicy) Mode() policy.ProcessingMode { + return policy.ProcessingMode{RequestBodyMode: policy.BodyModeBuffer} +} + +func (p *mutatingBodyPolicy) OnRequestBody(_ context.Context, ctx *policy.RequestContext, _ map[string]interface{}) policy.RequestAction { + if p.seen != nil && ctx.Body != nil { + *p.seen = ctx.Body.Content + } + return policy.UpstreamRequestModifications{Body: p.replacement} +} + +// requestBodyMutation pulls the forwarded body and the Content-Length/Content-Encoding +// header operations out of an ext_proc body response. +func requestBodyMutation(t *testing.T, resp *extprocv3.ProcessingResponse) (body []byte, setHeaders map[string]string, removed []string) { + t.Helper() + bodyResp := resp.GetRequestBody() + require.NotNil(t, bodyResp, "expected a body-phase response") + require.NotNil(t, bodyResp.Response.BodyMutation, "expected a body mutation") + + body = bodyResp.Response.BodyMutation.GetBody() + setHeaders = map[string]string{} + for _, h := range bodyResp.Response.HeaderMutation.GetSetHeaders() { + setHeaders[strings.ToLower(h.Header.Key)] = string(h.Header.RawValue) + } + removed = bodyResp.Response.HeaderMutation.GetRemoveHeaders() + return body, setHeaders, removed +} + +// A gzip request whose body a policy rewrote must reach the upstream re-compressed. +// Forwarding plaintext while keeping `content-encoding: gzip` is silently wrong: the +// upstream fails to inflate a body it was told is compressed. +func TestDeferredBinding_ModifiedCompressedBodyIsRecompressed(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + + replacement := []byte(`{"method":"SendMessage","enriched":true}`) + var policySaw []byte + f.operationChain("SendMessage", &mutatingBodyPolicy{replacement: replacement, seen: &policySaw}) + + execCtx := f.bindPendingWithHeaders(t, "POST|/rpc|example.com", map[string]string{ + ":method": "POST", ":path": "/rpc", "content-encoding": "gzip", + }) + + plain := []byte(`{"method":"SendMessage"}`) + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: gzipBytes(t, plain), EndOfStream: true}) + require.NoError(t, err) + + assert.Equal(t, plain, policySaw, "the policy sees decoded bytes") + + forwarded, setHeaders, removed := requestBodyMutation(t, resp) + assert.NotEqual(t, replacement, forwarded, + "the modified body must not be forwarded as plaintext while Content-Encoding says gzip") + + // It is genuinely gzip, and it inflates back to what the policy produced. + inflated, err := decompressBody(forwarded, "gzip", testMaxDecompressedBytes) + require.NoError(t, err, "the forwarded body must be valid gzip") + assert.Equal(t, replacement, inflated) + + // Content-Length must describe the compressed bytes actually sent. + assert.Equal(t, fmt.Sprintf("%d", len(forwarded)), setHeaders["content-length"]) + assert.NotContains(t, removed, "content-encoding", "the encoding still applies, so it must be kept") +} + +// An uncompressed request on the deferred path forwards the modified body as-is and +// must not acquire a Content-Encoding. +func TestDeferredBinding_ModifiedUncompressedBodyIsForwardedVerbatim(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + + replacement := []byte(`{"method":"SendMessage","enriched":true}`) + f.operationChain("SendMessage", &mutatingBodyPolicy{replacement: replacement}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + forwarded, setHeaders, _ := requestBodyMutation(t, resp) + assert.Equal(t, replacement, forwarded) + assert.Equal(t, fmt.Sprintf("%d", len(replacement)), setHeaders["content-length"]) +} + +// recompressModifiedRequestBody must stay symmetric with decompressBody, or a modified +// body ends up described by a Content-Encoding that does not match its bytes. +func TestRecompressModifiedRequestBody_RoundTripsPerEncoding(t *testing.T) { + plain := []byte(`{"modified":true}`) + + for _, encoding := range []string{"gzip", "br"} { + t.Run(encoding, func(t *testing.T) { + f := newResolutionFixture(t) + ec := newPolicyExecutionContext(f.server, "POST|/rpc|example.com", ®istry.PolicyChain{}) + ec.requestContentEncoding = encoding + + mutation := &extprocv3.BodyMutation{Mutation: &extprocv3.BodyMutation_Body{Body: plain}} + headerOps := map[string][]*headerOp{} + + length := recompressModifiedRequestBody(ec, mutation, headerOps) + + encoded := mutation.GetBody() + assert.NotEqual(t, plain, encoded, "the body must actually be re-encoded") + assert.Equal(t, len(encoded), length, "Content-Length must describe the encoded bytes") + assert.Empty(t, headerOps["content-encoding"], "the encoding still applies, so it is kept") + + decoded, err := decompressBody(encoded, encoding, testMaxDecompressedBytes) + require.NoError(t, err) + assert.Equal(t, plain, decoded) + }) + } + + // An encoding neither side understands is passed through unchanged by both + // decompressBody and recompressBody, so the label stays accurate: the body was + // never decoded, and it is not re-encoded either. + t.Run("unsupported encoding passes through symmetrically", func(t *testing.T) { + f := newResolutionFixture(t) + ec := newPolicyExecutionContext(f.server, "POST|/rpc|example.com", ®istry.PolicyChain{}) + ec.requestContentEncoding = "exotic-codec" + + mutation := &extprocv3.BodyMutation{Mutation: &extprocv3.BodyMutation_Body{Body: plain}} + headerOps := map[string][]*headerOp{} + + length := recompressModifiedRequestBody(ec, mutation, headerOps) + + assert.Equal(t, plain, mutation.GetBody()) + assert.Equal(t, len(plain), length) + + passedThrough, err := decompressBody(plain, "exotic-codec", testMaxDecompressedBytes) + require.NoError(t, err) + assert.Equal(t, plain, passedThrough, + "decompressBody must pass the same encoding through, or the two would disagree") + }) +} + +// A streamed body mutation is re-compressed on its own per-chunk path, so the buffered +// helper must decline it rather than mangle it. +func TestRecompressModifiedRequestBody_IgnoresStreamedMutation(t *testing.T) { + f := newResolutionFixture(t) + ec := newPolicyExecutionContext(f.server, "POST|/rpc|example.com", ®istry.PolicyChain{}) + ec.requestContentEncoding = "gzip" + + mutation := &extprocv3.BodyMutation{ + Mutation: &extprocv3.BodyMutation_StreamedResponse{StreamedResponse: &extprocv3.StreamedBodyResponse{}}, + } + assert.Zero(t, recompressModifiedRequestBody(ec, mutation, map[string][]*headerOp{})) +} + +// ─── Analytics on a deferred short-circuit ─────────────────────────────────── + +// analyticsPolicy contributes analytics metadata at whichever phase it is wired for. +type analyticsPolicy struct { + headerMetadata map[string]any + bodyMetadata map[string]any + rejectStatus int +} + +func (p *analyticsPolicy) Mode() policy.ProcessingMode { + mode := policy.ProcessingMode{} + if p.headerMetadata != nil { + mode.RequestHeaderMode = policy.HeaderModeProcess + } + if p.bodyMetadata != nil || p.rejectStatus != 0 { + mode.RequestBodyMode = policy.BodyModeBuffer + } + return mode +} + +func (p *analyticsPolicy) OnRequestHeaders(_ context.Context, _ *policy.RequestHeaderContext, _ map[string]interface{}) policy.RequestHeaderAction { + if p.headerMetadata == nil { + return nil + } + return policy.UpstreamRequestHeaderModifications{AnalyticsMetadata: p.headerMetadata} +} + +func (p *analyticsPolicy) OnRequestBody(_ context.Context, _ *policy.RequestContext, _ map[string]interface{}) policy.RequestAction { + if p.rejectStatus != 0 { + return policy.ImmediateResponse{ + StatusCode: p.rejectStatus, + Body: []byte(`{"error":"denied"}`), + AnalyticsMetadata: map[string]any{"denied_by": "quota"}, + } + } + if p.bodyMetadata == nil { + return nil + } + return policy.UpstreamRequestModifications{AnalyticsMetadata: p.bodyMetadata} +} + +func analyticsFromResponse(t *testing.T, resp *extprocv3.ProcessingResponse) map[string]interface{} { + t.Helper() + require.NotNil(t, resp.DynamicMetadata) + ns := resp.DynamicMetadata.Fields[constants.ExtProcFilterName] + require.NotNil(t, ns, "expected the ext_proc dynamic metadata namespace") + data := ns.GetStructValue().GetFields()["analytics_data"] + require.NotNil(t, data, "expected an analytics_data payload the ALS access log can read") + return data.GetStructValue().AsMap() +} + +// A rejection at the deferred body phase is the only ext_proc response the request +// produces — the pending request-headers response carried no policy metadata at all. +// So it has to carry everything the policies that already ran contributed, or the +// request loses fields in traffic logging. +func TestDeferredBinding_ShortCircuitKeepsEarlierAnalytics(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + + f.operationChain("SendMessage", + &analyticsPolicy{headerMetadata: map[string]any{"auth_subject": "user-7"}}, + &analyticsPolicy{bodyMetadata: map[string]any{"payload_kind": "jsonrpc"}}, + &analyticsPolicy{rejectStatus: 429}, + ) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + resp, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + + require.NotNil(t, resp.GetImmediateResponse()) + analytics := analyticsFromResponse(t, resp) + + assert.Equal(t, "user-7", analytics["auth_subject"], + "a header policy that ran in this same callback must not lose its analytics") + assert.Equal(t, "jsonrpc", analytics["payload_kind"], + "a body policy that ran before the rejecting one must not lose its analytics") + assert.Equal(t, "quota", analytics["denied_by"], + "the rejecting policy's own metadata must be present too") +} + +// The same aggregation applies when the header policies are what rejects. +func TestDeferredBinding_HeaderShortCircuitKeepsEarlierAnalytics(t *testing.T) { + f := newResolutionFixture(t) + ec := newPolicyExecutionContext(f.server, "POST|/rpc|example.com", ®istry.PolicyChain{}) + ec.buildRequestContexts(&extprocv3.HttpHeaders{ + Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{{Key: ":method", RawValue: []byte("POST")}}}, + }, RouteMetadata{RouteName: "POST|/rpc|example.com"}) + ec.analyticsMetadata["collected_at"] = "request_headers" + + headerResult := &executor.RequestHeaderExecutionResult{ + ShortCircuited: true, + FinalAction: policy.ImmediateResponse{ + StatusCode: 401, + AnalyticsMetadata: map[string]any{"denied_by": "jwt-auth"}, + }, + Results: []executor.RequestHeaderPolicyResult{ + {Action: policy.UpstreamRequestHeaderModifications{AnalyticsMetadata: map[string]any{"trace_id": "abc"}}}, + }, + } + + resp, err := TranslateRequestBodyActionsWithHeaderMerge(headerResult, &executor.RequestExecutionResult{}, ec) + require.NoError(t, err) + + analytics := analyticsFromResponse(t, resp) + assert.Equal(t, "request_headers", analytics["collected_at"], "context-accumulated metadata survives") + assert.Equal(t, "abc", analytics["trace_id"], "an earlier header policy's metadata survives") + assert.Equal(t, "jwt-auth", analytics["denied_by"]) +} + +// ─── Span attributes for a body-resolved route ─────────────────────────────── + +// The chain key is the attribute that answers "which chain did this operation get?", +// and on a deferred route it is only known at the body callback. Recording "" at the +// header callback would make the attribute useless for its primary consumer. +func TestRecordResolutionAttributes_SkipsUnknownChainKey(t *testing.T) { + f := newResolutionFixture(t) + + pending := newPolicyExecutionContext(f.server, "POST|/rpc|example.com", nil) + pending.resolverName = "a2a-jsonrpc" + rec := &recordingSpan{} + pending.recordResolutionAttributes(rec) + assert.Equal(t, map[string]string{constants.AttrResolverName: "a2a-jsonrpc"}, rec.attrs, + "no chain key is stamped before one has been selected") + + pending.chainKey = "POST|/op-one|example.com" + bound := &recordingSpan{} + pending.recordResolutionAttributes(bound) + assert.Equal(t, map[string]string{ + constants.AttrResolverName: "a2a-jsonrpc", + constants.AttrPolicyChainKey: "POST|/op-one|example.com", + }, bound.attrs) + + // An identity route stamps nothing: it has no resolver, and its chain key is the + // route name already on the span. + identity := newPolicyExecutionContext(f.server, "GET|/pets|example.com", nil) + identity.chainKey = "GET|/pets|example.com" + none := &recordingSpan{} + identity.recordResolutionAttributes(none) + assert.Empty(t, none.attrs) +} + +// The bound chain key really does reach a span once the body callback has run. +func TestDeferredBinding_ChainKeyIsRecordedAfterBinding(t *testing.T) { + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + f := newResolutionFixture(t, r) + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + execCtx := f.bindPending(t, "POST|/rpc|example.com") + + atHeaders := &recordingSpan{} + execCtx.recordResolutionAttributes(atHeaders) + assert.NotContains(t, atHeaders.attrs, constants.AttrPolicyChainKey) + + _, err := execCtx.processRequestBody(context.Background(), + &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}) + require.NoError(t, err) + require.True(t, execCtx.boundAtBodyPhase) + + atBody := &recordingSpan{} + execCtx.recordResolutionAttributes(atBody) + assert.Equal(t, operationChainKey("SendMessage"), atBody.attrs[constants.AttrPolicyChainKey]) + assert.Equal(t, "body", atBody.attrs[constants.AttrResolverName]) +} + +// recordingSpan is a trace.Span that only remembers the string attributes set on it, +// which is all these assertions need. +type recordingSpan struct { + noop.Span + attrs map[string]string +} + +func (s *recordingSpan) IsRecording() bool { return true } + +func (s *recordingSpan) SetAttributes(kv ...attribute.KeyValue) { + if s.attrs == nil { + s.attrs = map[string]string{} + } + for _, a := range kv { + s.attrs[string(a.Key)] = a.Value.AsString() + } +} + +// The end-to-end version of the span assertion, driving handleProcessingPhase across +// both callbacks with a real span recorder. It is what proves the attribute reaches a +// recorded span rather than only that the helper would stamp it. +func TestDeferredBinding_SpanCarriesResolvedChainKeyEndToEnd(t *testing.T) { + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + + r := &fakeOperationResolver{name: "body", reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, bodyField: "method"} + reg := resolver.NewRegistry() + require.NoError(t, reg.Register(r)) + reg.Freeze() + + k := NewKernel() + server := NewExternalProcessorServer(k, executor.NewChainExecutor(nil, nil, tp.Tracer("test")), + config.TracingConfig{}, "", testMaxDecompressedBytes, testMaxDecompressedBytes) + server.tracer = tp.Tracer("test") + + f := &resolutionFixture{server: server, kernel: k, resolvers: reg, t: t} + f.route("POST|/rpc|example.com", resolver.RouteResolution{ + ResolverName: "body", + }) + f.operationChain("SendMessage", &testutils.NoopPolicy{}) + + ctx, rootSpan := tp.Tracer("test").Start(context.Background(), "root") + + var execCtx *PolicyExecutionContext + headerResp, err := server.handleProcessingPhase(ctx, + headersRequest("POST|/rpc|example.com", false, map[string]string{":method": "POST", ":path": "/rpc"}), + &execCtx, rootSpan) + require.NoError(t, err) + require.NotNil(t, headerResp.ModeOverride, "the header response must ask Envoy to buffer") + + bodyResp, err := server.handleProcessingPhase(ctx, &extprocv3.ProcessingRequest{ + Request: &extprocv3.ProcessingRequest_RequestBody{ + RequestBody: &extprocv3.HttpBody{Body: []byte(`{"method":"SendMessage"}`), EndOfStream: true}, + }, + }, &execCtx, rootSpan) + require.NoError(t, err) + require.NotNil(t, bodyResp.GetRequestBody()) + + rootSpan.End() + + var root sdktrace.ReadOnlySpan + for _, s := range sr.Ended() { + if s.Name() == "root" { + root = s + } + } + require.NotNil(t, root, "the root span must have been recorded") + + attrs := map[string]string{} + for _, a := range root.Attributes() { + attrs[string(a.Key)] = a.Value.AsString() + } + assert.Equal(t, "body", attrs[constants.AttrResolverName]) + assert.Equal(t, operationChainKey("SendMessage"), attrs[constants.AttrPolicyChainKey], + "the resolved chain key must reach the span, which only the body callback can do") +} + +// ─── Existing-kind response compatibility ──────────────────────────────────── + +// bufferedResponsePolicy needs the whole response body — a guardrail or a redaction step. +type bufferedResponsePolicy struct{ testutils.NoopPolicy } + +func (p *bufferedResponsePolicy) Mode() policy.ProcessingMode { + return policy.ProcessingMode{ResponseBodyMode: policy.BodyModeBuffer} +} + +// chunkedJSONHeaders is an ordinary unary response that happens to use chunked transfer +// framing. +func chunkedJSONHeaders() *extprocv3.HttpHeaders { + return &extprocv3.HttpHeaders{Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{ + {Key: ":status", RawValue: []byte("200")}, + {Key: "content-type", RawValue: []byte("application/json")}, + {Key: "transfer-encoding", RawValue: []byte("chunked")}, + }}} +} + +// sseHeaders is an upstream response that is genuinely a stream. +func sseHeaders() *extprocv3.HttpHeaders { + return &extprocv3.HttpHeaders{Headers: &corev3.HeaderMap{Headers: []*corev3.HeaderValue{ + {Key: ":status", RawValue: []byte("200")}, + {Key: "content-type", RawValue: []byte("text/event-stream")}, + }}} +} + +// A resolved route must not change how the response body is delivered. The decision comes +// from the chain and the upstream response headers, exactly as it did before resolution +// existed: a chain that can only buffer buffers, and is never failed closed over the +// shape of the response. +func TestResolvedRoute_ResponseDeliveryIsUnchanged(t *testing.T) { + for name, headers := range map[string]*extprocv3.HttpHeaders{ + "sse upstream": sseHeaders(), + "chunked upstream": chunkedJSONHeaders(), + } { + t.Run(name, func(t *testing.T) { + f := newResolutionFixture(t) + f.route("GET|/chat|example.com", resolver.RouteResolution{}) + f.chain("GET|/chat|example.com", &bufferedResponsePolicy{}) + + var execCtx *PolicyExecutionContext + _, _, _ = f.server.initializeExecutionContext(context.Background(), + headersRequest("GET|/chat|example.com", true, map[string]string{":method": "GET"}), &execCtx) + require.NotNil(t, execCtx) + + resp, err := execCtx.processResponseHeaders(context.Background(), headers) + require.NoError(t, err) + assert.Nil(t, resp.GetImmediateResponse(), + "resolution must not introduce a response-phase failure") + assert.NotNil(t, resp.GetResponseHeaders()) + assert.False(t, execCtx.isStreamingResponse, + "a buffered-only chain buffers, exactly as before") + }) + } +} diff --git a/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go b/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go index c63f1277e..9fc52722f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go +++ b/gateway/gateway-runtime/policy-engine/internal/kernel/translator.go @@ -21,6 +21,7 @@ package kernel import ( "fmt" "log/slog" + "maps" "strings" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/utils" @@ -245,13 +246,7 @@ func translateRequestActionsCore(result *executor.RequestExecutionResult, execCt response := &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extprocv3.ImmediateResponse{ - Status: &typev3.HttpStatus{ - Code: typev3.StatusCode(immResp.StatusCode), - }, - Headers: buildHeaderValueOptions(immResp.Headers), - Body: immResp.Body, - }, + ImmediateResponse: buildImmediateResponse(immResp), }, } @@ -419,20 +414,7 @@ func translateRequestActionsCore(result *executor.RequestExecutionResult, execCt // Re-compress request body if a policy modified it, to preserve the original Content-Encoding. // If no policy modified the body, the original compressed bytes are forwarded unchanged. if bodyModified && execCtx.requestContentEncoding != "" { - originalBody := out.BodyMutation.Mutation.(*extprocv3.BodyMutation_Body).Body - recompressed, err := recompressBody(originalBody, execCtx.requestContentEncoding) - if err != nil { - slog.Warn("Failed to re-compress request body, sending uncompressed", - "encoding", execCtx.requestContentEncoding, - "error", err, - ) - // Remove Content-Encoding so the upstream does not try to decompress an uncompressed body. - headerOps["content-encoding"] = append(headerOps["content-encoding"], &headerOp{opType: "remove", value: ""}) - finalBodyLength = len(originalBody) - } else { - out.BodyMutation.Mutation.(*extprocv3.BodyMutation_Body).Body = recompressed - finalBodyLength = len(recompressed) - } + finalBodyLength = recompressModifiedRequestBody(execCtx, out.BodyMutation, headerOps) } // Remove any content-length headers from policy operations if we're managing it ourselves @@ -451,66 +433,158 @@ func translateRequestActionsCore(result *executor.RequestExecutionResult, execCt return out, nil } +// recompressModifiedRequestBody re-compresses a policy-modified request body so the +// bytes forwarded upstream still match the Content-Encoding the client sent, and +// returns the length to advertise as Content-Length. +// +// It is shared by every path that can emit a modified request body — the header-phase +// translation and the header+body merge used by the inline no-body and deferred +// (body-phase-bound) paths — because the failure mode is silent: plaintext bytes still +// labelled `content-encoding: gzip` reach the upstream, which then fails to inflate +// them. Only re-encoding can fix that; on failure the header is dropped instead, so +// the upstream at least sees an honest description of what it received. +func recompressModifiedRequestBody( + execCtx *PolicyExecutionContext, + bodyMutation *extprocv3.BodyMutation, + headerOps map[string][]*headerOp, +) int { + mutated, ok := bodyMutation.GetMutation().(*extprocv3.BodyMutation_Body) + if !ok { + // A streamed mutation is recompressed per chunk on its own path. + return 0 + } + + recompressed, err := recompressBody(mutated.Body, execCtx.requestContentEncoding) + if err != nil { + slog.Warn("Failed to re-compress request body, sending uncompressed", + "encoding", execCtx.requestContentEncoding, + "error", err, + ) + // Remove Content-Encoding so the upstream does not try to decompress an uncompressed body. + headerOps["content-encoding"] = append(headerOps["content-encoding"], &headerOp{opType: "remove", value: ""}) + return len(mutated.Body) + } + mutated.Body = recompressed + return len(recompressed) +} + +// buildImmediateResponse is the single construction site for an ext_proc +// ImmediateResponse produced by a policy short-circuit — an auth denial, a rate +// limit, a guardrail rejection. Every phase routes through it so a new +// short-circuit path cannot be added that builds one a different way. +// +// Engine-generated faults (handlePolicyError, handlePayloadTooLarge) deliberately +// do NOT come through here: an internal failure stays a sterile generic response +// built from nothing but its own error kind (error-handling.md). +func buildImmediateResponse(immResp policy.ImmediateResponse) *extprocv3.ImmediateResponse { + return &extprocv3.ImmediateResponse{ + Status: &typev3.HttpStatus{Code: typev3.StatusCode(immResp.StatusCode)}, + Headers: buildHeaderValueOptions(immResp.Headers), + Body: immResp.Body, + } +} + +// collectShortCircuitAnalytics builds the analytics payload for a rejection raised +// part-way through a chain: everything the policies that already ran contributed, then +// the rejecting policy's own metadata on top. +// +// Dropping the earlier contributions would make a denied request lose fields in — or +// vanish from — traffic logging, because an ImmediateResponse is the only ext_proc +// response that request will produce. That is worst on the deferred (body-phase-bound) +// path: its request-headers response carries no policy metadata at all, so there is no +// earlier response for the ALS line to have picked anything up from. +// +// headerResults and bodyResults may each be nil, for a caller where that phase has not +// run. +func collectShortCircuitAnalytics( + execCtx *PolicyExecutionContext, + headerResults []executor.RequestHeaderPolicyResult, + bodyResults []executor.RequestPolicyResult, + immResp policy.ImmediateResponse, +) map[string]any { + out := make(map[string]any, len(execCtx.analyticsMetadata)+len(immResp.AnalyticsMetadata)) + maps.Copy(out, execCtx.analyticsMetadata) + + for _, pr := range headerResults { + if pr.Skipped || pr.Action == nil { + continue + } + if mods, ok := pr.Action.(policy.UpstreamRequestHeaderModifications); ok { + mergePolicyAnalytics(execCtx, out, mods.AnalyticsMetadata, mods.AnalyticsHeaderFilter) + } + } + for _, pr := range bodyResults { + if pr.Skipped || pr.Action == nil { + continue + } + if mods, ok := pr.Action.(policy.UpstreamRequestModifications); ok { + mergePolicyAnalytics(execCtx, out, mods.AnalyticsMetadata, mods.AnalyticsHeaderFilter) + } + } + + // The rejecting policy wins on any key it also sets. + maps.Copy(out, immResp.AnalyticsMetadata) + return out +} + +// mergePolicyAnalytics folds one policy result's analytics contribution into dest, +// resolving its header filter against the request headers as they stand. +func mergePolicyAnalytics( + execCtx *PolicyExecutionContext, + dest map[string]any, + metadata map[string]any, + dropAction policy.DropHeaderAction, +) { + maps.Copy(dest, metadata) + if dropAction.Action != "" || len(dropAction.Headers) > 0 { + dest["request_headers"] = finalizeAnalyticsHeaders(dropAction, execCtx.requestBodyCtx.Headers.GetAll()) + } +} + +// requestHeaderShortCircuitResponse builds the ImmediateResponse for a +// request-header policy that short-circuited, or reports handled=false when the +// chain did not short-circuit with one. Shared by the header-phase translator and +// the deferred (body-phase) binding path, where header policies run late and can +// short-circuit there instead. +func requestHeaderShortCircuitResponse( + result *executor.RequestHeaderExecutionResult, + execCtx *PolicyExecutionContext, +) (*extprocv3.ProcessingResponse, bool, error) { + if !result.ShortCircuited || result.FinalAction == nil { + return nil, false, nil + } + immResp, ok := result.FinalAction.(policy.ImmediateResponse) + if !ok { + return nil, false, nil + } + + response := &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: buildImmediateResponse(immResp), + }, + } + + // Preserve request-header-phase analytics metadata from policies that executed + // before the short-circuit (e.g. request headers captured by the collector system + // policy) so an immediate response like a 401 still carries it to the ALS access + // log. Without this, a short-circuiting policy (auth) drops the metadata of any + // earlier policy and the global traffic-logging publisher's line for that denied + // request would be missing it. + shortCircuitAnalyticsData := collectShortCircuitAnalytics(execCtx, result.Results, nil, immResp) + + analyticsStruct, err := buildAnalyticsStruct(shortCircuitAnalyticsData, execCtx) + if err != nil { + return nil, true, fmt.Errorf("failed to build analytics metadata for immediate response: %w", err) + } + response.DynamicMetadata = buildDynamicMetadata(analyticsStruct, nil, immResp.DynamicMetadata) + return response, true, nil +} + // TranslateRequestHeaderActions converts a RequestHeaderExecutionResult (from ExecuteRequestHeaderPolicies) // to an ext_proc response. The ModeOverride instructs Envoy on how to deliver the remaining phases. func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult, chain *registry.PolicyChain, execCtx *PolicyExecutionContext) (*extprocv3.ProcessingResponse, error) { - // Check for short-circuit with immediate response - if result.ShortCircuited && result.FinalAction != nil { - if immResp, ok := result.FinalAction.(policy.ImmediateResponse); ok { - response := &extprocv3.ProcessingResponse{ - Response: &extprocv3.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extprocv3.ImmediateResponse{ - Status: &typev3.HttpStatus{ - Code: typev3.StatusCode(immResp.StatusCode), - }, - Headers: buildHeaderValueOptions(immResp.Headers), - Body: immResp.Body, - }, - }, - } - // Preserve request-header-phase analytics metadata from policies that - // executed before the short-circuit (e.g. request headers captured by - // the collector system policy) so an immediate response like a 401 - // still carries it to the ALS access log. Without this, a - // short-circuiting policy (auth) drops the metadata of any earlier - // policy and the global traffic-logging publisher's line for that - // denied request would be missing it. Mirrors translateRequestActionsCore's - // short-circuit path. - shortCircuitAnalyticsData := make(map[string]any) - for key, value := range execCtx.analyticsMetadata { - shortCircuitAnalyticsData[key] = value - } - for _, policyResult := range result.Results { - if policyResult.Skipped || policyResult.Action == nil { - continue - } - mods, ok := policyResult.Action.(policy.UpstreamRequestHeaderModifications) - if !ok { - continue - } - if mods.AnalyticsMetadata != nil { - for key, value := range mods.AnalyticsMetadata { - shortCircuitAnalyticsData[key] = value - } - } - dropAction := mods.AnalyticsHeaderFilter - if dropAction.Action != "" || len(dropAction.Headers) > 0 { - originalHeaders := execCtx.requestBodyCtx.Headers.GetAll() - shortCircuitAnalyticsData["request_headers"] = finalizeAnalyticsHeaders(dropAction, originalHeaders) - } - } - if immResp.AnalyticsMetadata != nil { - for key, value := range immResp.AnalyticsMetadata { - shortCircuitAnalyticsData[key] = value - } - } - analyticsStruct, err := buildAnalyticsStruct(shortCircuitAnalyticsData, execCtx) - if err != nil { - return nil, fmt.Errorf("failed to build analytics metadata for immediate response: %w", err) - } - response.DynamicMetadata = buildDynamicMetadata(analyticsStruct, nil, immResp.DynamicMetadata) - return response, nil - } + if response, handled, err := requestHeaderShortCircuitResponse(result, execCtx); handled { + return response, err } // Collect header ops, path/method mutations, and analytics from all results @@ -610,37 +684,136 @@ func TranslateRequestHeaderActions(result *executor.RequestHeaderExecutionResult return response, nil } +// mergedRequestResult holds the combined output of a request-header pass and a +// request-body pass over the same chain, ready to be wrapped in whichever ext_proc +// phase response the caller owes Envoy. +type mergedRequestResult struct { + HeaderMutation *extprocv3.HeaderMutation + BodyMutation *extprocv3.BodyMutation + AnalyticsData map[string]any + DynamicMetadata map[string]map[string]interface{} + Mutations RequestMutations + ImmediateResp *extprocv3.ProcessingResponse +} + // TranslateRequestHeaderActionsWithBodyMerge merges results from both the request-headers // phase and the request-body phase into a single RequestHeaders ext_proc response. // This is used when a request carries no body (GET, Content-Length: 0, EndOfStream in headers) // so body policies are executed inline during the headers phase. -// -// The caller must set execCtx.requestBodyProcessedInline = true before calling this function -// so that getModeOverride() instructs Envoy to skip the RequestBody phase (mode = NONE). func TranslateRequestHeaderActionsWithBodyMerge( headerResult *executor.RequestHeaderExecutionResult, bodyResult *executor.RequestExecutionResult, execCtx *PolicyExecutionContext, ) (*extprocv3.ProcessingResponse, error) { - // Only body policies can short-circuit here: this function is called exclusively - // when header policies did NOT short-circuit (see processRequestBodyForEmptyRequest). + merged, err := mergeRequestHeaderAndBodyResults(headerResult, bodyResult, execCtx) + if err != nil { + return nil, err + } + if merged.ImmediateResp != nil { + return merged.ImmediateResp, nil + } + + response := &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extprocv3.HeadersResponse{ + Response: &extprocv3.CommonResponse{ + HeaderMutation: merged.HeaderMutation, + BodyMutation: merged.BodyMutation, + ClearRouteCache: true, + }, + }, + }, + ModeOverride: execCtx.getModeOverride(), + } + + analyticsStruct, err := buildAnalyticsStruct(merged.AnalyticsData, execCtx) + if err != nil { + return nil, fmt.Errorf("failed to build analytics metadata: %w", err) + } + response.DynamicMetadata = buildDynamicMetadata(analyticsStruct, &merged.Mutations, merged.DynamicMetadata) + return response, nil +} + +// TranslateRequestBodyActionsWithHeaderMerge is the mirror image of +// TranslateRequestHeaderActionsWithBodyMerge, for a route whose policy chain is +// only selected at the request-body callback: both the header-phase and body-phase +// policies run there, so both sets of mutations must be emitted on the *body* +// response. This is the one place where a header mutation is not carried by the +// header-phase response — see the deferred-binding path in execution_context.go. +func TranslateRequestBodyActionsWithHeaderMerge( + headerResult *executor.RequestHeaderExecutionResult, + bodyResult *executor.RequestExecutionResult, + execCtx *PolicyExecutionContext, +) (*extprocv3.ProcessingResponse, error) { + merged, err := mergeRequestHeaderAndBodyResults(headerResult, bodyResult, execCtx) + if err != nil { + return nil, err + } + if merged.ImmediateResp != nil { + return merged.ImmediateResp, nil + } + + response := &extprocv3.ProcessingResponse{ + Response: &extprocv3.ProcessingResponse_RequestBody{ + RequestBody: &extprocv3.BodyResponse{ + Response: &extprocv3.CommonResponse{ + HeaderMutation: merged.HeaderMutation, + BodyMutation: merged.BodyMutation, + ClearRouteCache: true, + }, + }, + }, + // getModeOverride returns nil for a body-phase-bound route: Envoy applies a + // ModeOverride only on responses to header callbacks and ignores it on body + // callbacks, so the resolved chain's response-body mode is returned later, + // from the response-header callback. + ModeOverride: execCtx.getModeOverride(), + } + + analyticsStruct, err := buildAnalyticsStruct(merged.AnalyticsData, execCtx) + if err != nil { + return nil, fmt.Errorf("failed to build analytics metadata: %w", err) + } + response.DynamicMetadata = buildDynamicMetadata(analyticsStruct, &merged.Mutations, merged.DynamicMetadata) + return response, nil +} + +// mergeRequestHeaderAndBodyResults collects the mutations, analytics and dynamic +// metadata of a header pass and a body pass over one chain into a single result. +// Either pass may short-circuit; the first that does produces the ImmediateResp. +func mergeRequestHeaderAndBodyResults( + headerResult *executor.RequestHeaderExecutionResult, + bodyResult *executor.RequestExecutionResult, + execCtx *PolicyExecutionContext, +) (*mergedRequestResult, error) { + // A header-phase short-circuit is only reachable on the deferred-binding path, + // where header policies run at the body callback. The inline no-body path calls + // this only after header policies have already been found not to short-circuit, + // so the check is inert there. + if response, handled, err := requestHeaderShortCircuitResponse(headerResult, execCtx); handled { + if err != nil { + return nil, err + } + return &mergedRequestResult{ImmediateResp: response}, nil + } + if bodyResult.ShortCircuited && bodyResult.FinalAction != nil { if immResp, ok := bodyResult.FinalAction.(policy.ImmediateResponse); ok { response := &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extprocv3.ImmediateResponse{ - Status: &typev3.HttpStatus{Code: typev3.StatusCode(immResp.StatusCode)}, - Headers: buildHeaderValueOptions(immResp.Headers), - Body: immResp.Body, - }, + ImmediateResponse: buildImmediateResponse(immResp), }, } - analyticsStruct, err := buildAnalyticsStruct(immResp.AnalyticsMetadata, execCtx) + // Both phases' earlier results count here: on the deferred path the + // header policies ran in this same callback, so their analytics have + // never been emitted on any previous response. + analyticsStruct, err := buildAnalyticsStruct( + collectShortCircuitAnalytics(execCtx, headerResult.Results, bodyResult.Results, immResp), execCtx) if err != nil { return nil, fmt.Errorf("failed to build analytics metadata for immediate response: %w", err) } response.DynamicMetadata = buildDynamicMetadata(analyticsStruct, nil, immResp.DynamicMetadata) - return response, nil + return &mergedRequestResult{ImmediateResp: response}, nil } } @@ -781,6 +954,14 @@ func TranslateRequestHeaderActionsWithBodyMerge( applyDefaultUpstream(execCtx, headerOps, dynamicMetadata) } + // A body-phase policy that rewrote the body must not leave plaintext behind a + // Content-Encoding header. This path carries a real compressed body whenever the + // chain was selected at the request-body callback, so the omission is not + // theoretical here. + if bodyModified && bodyMutation != nil && execCtx.requestContentEncoding != "" { + finalBodyLength = recompressModifiedRequestBody(execCtx, bodyMutation, headerOps) + } + if bodyModified { delete(headerOps, "content-length") } @@ -789,26 +970,13 @@ func TranslateRequestHeaderActionsWithBodyMerge( setContentLengthHeader(headerMutation, finalBodyLength) } - response := &extprocv3.ProcessingResponse{ - Response: &extprocv3.ProcessingResponse_RequestHeaders{ - RequestHeaders: &extprocv3.HeadersResponse{ - Response: &extprocv3.CommonResponse{ - HeaderMutation: headerMutation, - BodyMutation: bodyMutation, - ClearRouteCache: true, - }, - }, - }, - ModeOverride: execCtx.getModeOverride(), - } - - analyticsStruct, err := buildAnalyticsStruct(analyticsData, execCtx) - if err != nil { - return nil, fmt.Errorf("failed to build analytics metadata: %w", err) - } - response.DynamicMetadata = buildDynamicMetadata(analyticsStruct, &mutations, dynamicMetadata) - - return response, nil + return &mergedRequestResult{ + HeaderMutation: headerMutation, + BodyMutation: bodyMutation, + AnalyticsData: analyticsData, + DynamicMetadata: dynamicMetadata, + Mutations: mutations, + }, nil } // TranslateResponseHeaderActions converts a ResponseHeaderExecutionResult (from ExecuteResponseHeaderPolicies) @@ -820,13 +988,7 @@ func TranslateResponseHeaderActions(result *executor.ResponseHeaderExecutionResu if immResp, ok := result.FinalAction.(policy.ImmediateResponse); ok { response := &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extprocv3.ImmediateResponse{ - Status: &typev3.HttpStatus{ - Code: typev3.StatusCode(immResp.StatusCode), - }, - Headers: buildHeaderValueOptions(immResp.Headers), - Body: immResp.Body, - }, + ImmediateResponse: buildImmediateResponse(immResp), }, } analyticsStruct, err := buildAnalyticsStruct(immResp.AnalyticsMetadata, execCtx) @@ -922,11 +1084,7 @@ func TranslateResponseHeaderActionsWithBodyMerge( if immResp, ok := bodyResult.FinalAction.(policy.ImmediateResponse); ok { response := &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extprocv3.ImmediateResponse{ - Status: &typev3.HttpStatus{Code: typev3.StatusCode(immResp.StatusCode)}, - Headers: buildHeaderValueOptions(immResp.Headers), - Body: immResp.Body, - }, + ImmediateResponse: buildImmediateResponse(immResp), }, } analyticsStruct, err := buildAnalyticsStruct(immResp.AnalyticsMetadata, execCtx) @@ -1162,13 +1320,7 @@ func translateResponseActionsCore(result *executor.ResponseExecutionResult, exec if immResp, ok := result.FinalAction.(policy.ImmediateResponse); ok { response := &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extprocv3.ImmediateResponse{ - Status: &typev3.HttpStatus{ - Code: typev3.StatusCode(immResp.StatusCode), - }, - Headers: buildHeaderValueOptions(immResp.Headers), - Body: immResp.Body, - }, + ImmediateResponse: buildImmediateResponse(immResp), }, } diff --git a/gateway/gateway-runtime/policy-engine/internal/metrics/metrics.go b/gateway/gateway-runtime/policy-engine/internal/metrics/metrics.go index 2422e20b4..bb4d15249 100644 --- a/gateway/gateway-runtime/policy-engine/internal/metrics/metrics.go +++ b/gateway/gateway-runtime/policy-engine/internal/metrics/metrics.go @@ -62,6 +62,22 @@ var ( StreamErrorsTotal CounterVec RouteLookupFailuresTotal Counter PanicRecoveriesTotal CounterVec + + // ResolutionFailuresTotal counts requests whose logical operation could not be + // resolved to a policy chain, labelled by resolver name and FailureKind. It + // sits alongside RouteLookupFailuresTotal rather than replacing it: that one + // counts a route with no chain at all, this one counts a route that resolved + // to no chain. Dashboards need both, because an unknown-operation failure is + // rendered as an HTTP 404 that is otherwise indistinguishable from an Envoy + // route-not-found. + ResolutionFailuresTotal CounterVec + + // RouteResolutionIngestFailuresTotal counts routes dropped at xDS ingest + // because their resolution config is unusable — the reason labels emitted today + // are unknown_resolver, invalid_resolver_config and prepare_failed. A non-zero + // value here means part of a deployment is not being served, which no + // request-time metric shows. + RouteResolutionIngestFailuresTotal CounterVec ) // initMetrics initializes all metric variables. @@ -275,6 +291,24 @@ func initMetrics() { }, []string{"component"}, ) + + ResolutionFailuresTotal = newCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "resolution_failures_total", + Help: "Total number of requests whose logical operation could not be resolved to a policy chain", + }, + []string{"resolver", "kind"}, + ) + + RouteResolutionIngestFailuresTotal = newCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "route_resolution_ingest_failures_total", + Help: "Total number of routes skipped at xDS ingest because their resolution config is unusable", + }, + []string{"reason"}, + ) } func registerCounterVec(v CounterVec) { @@ -374,6 +408,8 @@ func initRegistry() { registerCounterVec(StreamErrorsTotal) registerCounter(RouteLookupFailuresTotal) registerCounterVec(PanicRecoveriesTotal) + registerCounterVec(ResolutionFailuresTotal) + registerCounterVec(RouteResolutionIngestFailuresTotal) Up.Set(1) } diff --git a/gateway/gateway-runtime/policy-engine/internal/resolver/resolver.go b/gateway/gateway-runtime/policy-engine/internal/resolver/resolver.go index 5b3bf52bf..f95206ead 100644 --- a/gateway/gateway-runtime/policy-engine/internal/resolver/resolver.go +++ b/gateway/gateway-runtime/policy-engine/internal/resolver/resolver.go @@ -16,56 +16,475 @@ * under the License. */ +// Package resolver derives the policy chain key for a request. +// +// Most API kinds identify their logical operation by HTTP method and path, so the +// Envoy route name already *is* the chain key. Multiplexed transports (A2A +// JSON-RPC, MCP, GraphQL) carry many logical operations on one HTTP route, so the +// operation has to be read out of the request itself. +// +// The registry holds resolver *factories*. Each route is prepared once, at xDS +// ingest, into an immutable PreparedResolver that captures everything static about +// that route — its API ID, vhost, context, and its own configuration. Two routes +// prepared by one factory are independent: one may need a buffered body while its +// sibling needs nothing at all. That is what lets a single factory serve both a +// transport that multiplexes every operation onto one route — where the operation is +// only knowable from the request — and one with a route per operation, whose operation +// is fixed at deploy time and needs no request inspection whatsoever. +// +// A prepared resolver composes its own chain key, with the shared helper +// (common/chainkey) and the partition it captured at preparation. What stays central +// is *validation*: this package checks that key against the route's own target and +// partition before the chain is looked up, so a resolver cannot reach a chain belonging +// to another API or vhost. The controller composes keys with the same helper when it +// emits the chains, which is what makes two transports of one logical operation select +// one chain without either being told about the other. +// +// at ingest: route config ──prepare──▶ immutable prepared resolver +// per request: request ──resolve──▶ one chain key +// per request: chain key ──validate──▶ bound chain package resolver -import "fmt" +import ( + "context" + "errors" + "fmt" + "sort" + "sync" -// PolicyChainResolver resolves which PolicyChain to apply for a given request. -// Like a policy, it declares what request data it needs before it can run. -type PolicyChainResolver interface { - // Name returns the resolver's registered name (e.g. "route-key", "mcp-tool"). + "github.com/wso2/api-platform/common/chainkey" +) + +// RouteKeyResolverName is the registered name of the identity resolver: the chain +// key is the route's own canonical chain key, with no request inspection at all. +// A route with an empty resolver_name is treated identically. +const RouteKeyResolverName = "route-key" + +// ProtocolVersion is the operation-resolution wire contract this binary implements. +// It is advertised in the xDS Node metadata so the control plane can withhold +// resolver-bearing routes from a runtime that predates them, instead of sending +// routes whose every request would fail to resolve. +// +// Bump it only for a change that an older runtime would mis-handle rather than +// merely ignore — a new required route field, or changed semantics for an existing +// one. Adding a resolver is covered by the advertised resolver list, not by this. +const ProtocolVersion = 1 + +// Resolver is a registered resolver factory. It holds no per-route state: the +// registry stores one instance per name for the process lifetime, and every route +// that names it gets its own PreparedResolver. +type Resolver interface { + // Name returns the resolver's registered name; it must match the + // resolver_name emitted on the wire by the controller. Name() string - // Requirements declares what request data the resolver needs. - Requirements() ResolverRequirements + // Prepare builds the immutable resolver for one route. It runs once per route at + // xDS ingest, so a resolver that must validate configuration, compile a schema or + // build an index does it here rather than per request. + // + // An error skips that one route — its requests then take the existing sterile 500 + // path — and increments an ingest failure metric. It must NOT NACK the snapshot: + // under State-of-the-World that keeps the previous version of every RouteConfig, so + // one bad deployment would freeze route updates for every API on the gateway. + Prepare(ResolverRouteConfig) (PreparedResolver, error) +} + +// PreparedResolver is one route's resolver, fixed at ingest. Implementations must be +// safe for concurrent use and must not mutate after Prepare returns. +type PreparedResolver interface { + // Requirements declares what must be available before Resolve is called. It is a + // property of this prepared route, not of the factory, and cannot be overridden by + // configuration: letting a transport that must read the body opt out of buffering + // would make correct resolution impossible. + Requirements() RequestRequirements - // Resolve returns the policy chain key for the given request context. - Resolve(ctx ResolverContext) (string, error) + // Resolve reads the request and returns the chain key it binds to. + // + // It must tolerate a RequestView whose Body is nil or empty, even on a route that + // declared BodyBuffered: a bodyless request (a GET, or any request whose headers are + // end-of-stream) gets no request-body callback from Envoy, so the kernel resolves at + // the header phase rather than waiting for a callback that cannot arrive. Treat that + // as the invalid request it usually is — return a classified *ResolutionError — and + // never index into Body without checking its length. + Resolve(context.Context, RequestView) (Resolution, error) } -// ResolverRequirements declares what request data a resolver needs. -type ResolverRequirements struct { - // BufferBody means the whole request body must be buffered before Resolve() is called. - BufferBody bool - // Headers means request headers must be available. +// StaticPreparedResolver is an optional optimisation for a route whose resolution is +// entirely known at ingest. The kernel stores the result and neither builds a +// RequestView nor calls Resolve on the request path. +// +// route-key implements it, which is what keeps every kind shipping today on a path +// that costs a field read and a string comparison. +type StaticPreparedResolver interface { + PreparedResolver + + // StaticResolution returns the resolution every request on this route produces. + StaticResolution() Resolution +} + +// BodyRequirement is whether a prepared resolver needs the request body. +type BodyRequirement uint8 + +const ( + // BodyNotRequired means the resolver decides from headers, path and its own + // configuration alone, so its chain is bound at the request-headers callback. + BodyNotRequired BodyRequirement = iota + + // BodyBuffered means the resolver reads the whole request body, which forces the + // kernel to defer chain selection to the request-body callback (see the deferred + // binding path in internal/kernel). + // + // It is a request for the body, not a guarantee of one. A request whose headers are + // end-of-stream carries no body and produces no body callback, so such a request is + // resolved at the header phase with RequestView.Body nil — see Resolve. + BodyBuffered +) + +// Valid reports whether b is a requirement this binary understands. An unrecognised +// value is rejected at preparation rather than guessed at: guessing "no body" would let a +// resolver that declared it needs the body run without one and select a chain from +// nothing. +func (b BodyRequirement) Valid() bool { + switch b { + case BodyNotRequired, BodyBuffered: + return true + default: + return false + } +} + +// String names the requirement for error text. +func (b BodyRequirement) String() string { + switch b { + case BodyNotRequired: + return "not-required" + case BodyBuffered: + return "buffered" + default: + return fmt.Sprintf("unknown(%d)", uint8(b)) + } +} + +// RequestRequirements declares what request data a prepared resolver needs. +// +// A static resolution needs none of it, so a StaticPreparedResolver must declare the +// zero value; PrepareRoute rejects any other combination rather than letting the static +// branch silently win over a stated requirement. +type RequestRequirements struct { + // Headers is currently advisory: the request view a resolver receives always carries + // the headers, so nothing in the engine reads this field. It is kept because a + // resolver declaring its inputs is the contract, and a future path that builds a + // narrower view would have to honour it. Headers bool + + Body BodyRequirement } -// ResolverContext contains request data available to the resolver. -type ResolverContext struct { +// BuffersBody reports whether this route defers chain selection to the body phase. +// +// Anything other than an explicit BodyNotRequired counts as needing the body. That is +// the conservative direction: providing a body to a resolver that did not want it costs +// a buffered callback, while withholding one from a resolver that did means it resolves +// from nothing. PrepareRoute rejects unrecognised values outright, so this is +// defence-in-depth rather than the primary guard. +func (r RequestRequirements) BuffersBody() bool { return r.Body != BodyNotRequired } + +// RequestView is the read-only view of the request handed to a prepared resolver. +// +// It carries only what varies per request. Static partition data (API ID, vhost, API +// context) is not copied in here: the prepared resolver captured it at ingest, which +// is both cheaper and narrower — a resolver cannot be handed a partition that differs +// from the one its keys are validated against. +type RequestView struct { RouteKey string - Headers map[string][]string - Body []byte + + Method string // upper-cased at extraction (GO-AUTH-006) + Path string + Headers map[string][]string + + // Body is the decoded request body, populated only for a route that declared + // BodyBuffered — and not even always then: it is nil when the request had no body at + // all, because a request whose headers are end-of-stream never reaches a body + // callback. A BodyBuffered resolver must therefore handle nil and empty alike, and + // must not assume a non-empty slice (see PreparedResolver.Resolve). + Body []byte +} + +// TargetKind is what a resolution's keys name, which decides how they are validated +// and how a missing chain is classified. +type TargetKind uint8 + +const ( + // TargetInvalid is the zero value and is always rejected. A resolver that forgets + // to set a target fails closed rather than defaulting into either set of semantics. + TargetInvalid TargetKind = iota + + // TargetDirectRoute means the key is the route's own chain key. A miss keeps the + // pre-resolution outcome for a route with no chain: the kernel's sterile 500, not a + // protocol-level error. + TargetDirectRoute + + // TargetOperation means the key is composed from a canonical protocol operation. A + // miss is classified from KnownToProtocol: a deployment problem when the protocol + // says the operation exists, and an unknown operation when it does not. + TargetOperation +) + +// String names the target for logs and error text. +func (t TargetKind) String() string { + switch t { + case TargetDirectRoute: + return "direct-route" + case TargetOperation: + return "operation" + default: + return "invalid" + } } -// Registry holds registered resolvers by name. -var Registry = map[string]PolicyChainResolver{} +// Resolution is what a prepared resolver made of the request. +type Resolution struct { + // Target is what ChainKey names. Required: an unset target is rejected. + Target TargetKind -// Register adds a resolver to the global registry. -func Register(r PolicyChainResolver) { - Registry[r.Name()] = r + // ChainKey is the one chain this request binds to. Exactly one chain runs per + // request, so the binder does no selecting — it validates this key and looks it up. + // + // Empty means the resolver could not identify anything to bind to, which is + // rejected as FailureInvalidRequest. + ChainKey string + + // KnownToProtocol reports that the operation is one the protocol itself defines. + // It decides how "no chain under this key" is classified: for a closed operation + // set (A2A's fixed operation enum) a missing chain means the deployment was built + // wrong, because the protocol says the operation exists; for an open one (MCP tool + // names) it means the client named something that does not exist. + KnownToProtocol bool } -// Get returns a resolver by name. -func Get(name string) (PolicyChainResolver, error) { - r, ok := Registry[name] - if !ok { - return nil, fmt.Errorf("resolver not found: %s", name) +// BoundResolution is the outcome of binding a resolution to a chain that exists. +type BoundResolution struct { + // ChainKey is the key whose chain was selected. + ChainKey string + + // Operation is the canonical operation whose chain ran, for telemetry. It is + // *derived* from ChainKey rather than reported separately by the resolver: with one + // key per resolution the two cannot legitimately differ, and a resolver that could + // name a third value would let telemetry say SendMessage while the GetTask chain — + // its authentication, its rate limits — actually ran. + // + // Empty for a direct route: there the route determined the chain, so the resolver + // identified no operation, and the chain key is already on the span. + Operation string +} + +// FailureKind classifies why resolution failed, so the kernel can pick a status and +// label a metric without inspecting error text. It never reaches the client: every +// failure is answered with the same sterile generic response. +type FailureKind string + +const ( + // FailureParse means the request payload could not be parsed at all. + FailureParse FailureKind = "parse" + // FailureInvalidRequest means the payload parsed but is not a valid request + // envelope for this protocol (this covers a resolver returning no chain key). + FailureInvalidRequest FailureKind = "invalid-request" + // FailureUnknownOperation means a well-formed request named an operation the + // protocol does not define — the client asked for something that does not exist. + // Distinct from FailureChainMissing, which is a deployment problem. + FailureUnknownOperation FailureKind = "unknown-operation" + // FailureMultiOperation means the request envelope carries more than one + // operation (a JSON-RPC batch), which no composition rule supports: one request + // selects one chain. Raised by the resolver that recognises the envelope, since + // only it can tell a batch from a single call. + FailureMultiOperation FailureKind = "multi-operation-unsupported" + // FailurePayloadTooLarge means the request body exceeded a configured ceiling + // before the resolver could run. + FailurePayloadTooLarge FailureKind = "payload-too-large" + // FailureUnsupportedEncoding means the request declared a content coding the + // engine cannot decode, or stacked several. The body is never handed to the + // resolver still encoded, because it would resolve to whatever the compressed + // frame happens to look like rather than to the operation the client sent. + FailureUnsupportedEncoding FailureKind = "unsupported-encoding" + // FailureUndecodableBody means the request declared a supported content coding + // but the body does not decode under it. + FailureUndecodableBody FailureKind = "undecodable-body" + // FailureUnknownResolver means the route names a resolver this binary does not + // have, so nothing about the request could be interpreted at all. + FailureUnknownResolver FailureKind = "unknown-resolver" + // FailureChainMissing means resolution succeeded — the operation is one the + // protocol defines — but no chain exists under its composed key. That is a + // controller construction error or xDS skew, not the protocol's "unknown + // operation" case — so it answers 500 rather than blaming the caller with a 404. + FailureChainMissing FailureKind = "chain-missing" + // FailureInternal is every unclassified resolver error, and every key a resolver + // returned that this package refused to accept. + FailureInternal FailureKind = "internal" +) + +// ResolutionError carries the classified reason a resolution failed. Unclassified +// resolver errors are wrapped as FailureInternal. The Cause is for internal logs only. +type ResolutionError struct { + Kind FailureKind + Cause error +} + +func (e *ResolutionError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("operation resolution failed (%s): %v", e.Kind, e.Cause) } - return r, nil + return fmt.Sprintf("operation resolution failed (%s)", e.Kind) +} + +// Unwrap exposes the underlying cause for errors.Is/As. The cause is for internal +// logs only; it is never rendered to a client (error-handling.md directive 1). +func (e *ResolutionError) Unwrap() error { return e.Cause } + +// ResolverRegistry is injected into the kernel and the xDS handler. Production uses +// one immutable default instance; tests construct independent registries with fake +// resolvers rather than mutating the production one. +type ResolverRegistry interface { + Get(name string) (Resolver, bool) + Names() []string +} + +// Registry is the concrete ResolverRegistry. It is mutable while being built and +// immutable once frozen, so nothing can register a resolver after the kernel and +// the xDS client have started reading it. +type Registry struct { + mu sync.RWMutex + byName map[string]Resolver + frozen bool +} + +// NewRegistry returns an empty, unfrozen registry. Tests use this to build +// independent registries; production uses DefaultRegistry. +func NewRegistry() *Registry { + return &Registry{byName: make(map[string]Resolver)} +} + +// Register adds a resolver. It fails on a duplicate name (two resolvers answering +// to one wire value is always a build mistake) and on a frozen registry. +func (r *Registry) Register(res Resolver) error { + if res == nil { + return errors.New("resolver: cannot register a nil resolver") + } + name := res.Name() + if name == "" { + return errors.New("resolver: cannot register a resolver with an empty name") + } + + r.mu.Lock() + defer r.mu.Unlock() + if r.frozen { + return fmt.Errorf("resolver: registry is frozen, cannot register %q", name) + } + if _, exists := r.byName[name]; exists { + return fmt.Errorf("resolver: %q is already registered", name) + } + r.byName[name] = res + return nil +} + +// MustRegister is Register for package init() use, where a failure is a build +// error rather than a runtime condition. +func (r *Registry) MustRegister(res Resolver) { + if err := r.Register(res); err != nil { + panic(err) + } +} + +// Freeze makes the registry immutable. Idempotent. +func (r *Registry) Freeze() { + r.mu.Lock() + defer r.mu.Unlock() + r.frozen = true +} + +// Frozen reports whether the registry has been frozen. +func (r *Registry) Frozen() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.frozen +} + +// Get returns the resolver factory registered under name. +func (r *Registry) Get(name string) (Resolver, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + res, ok := r.byName[name] + return res, ok +} + +// Names returns every registered resolver name, sorted. Used by the capability +// advertisement and the admin config dump. +func (r *Registry) Names() []string { + r.mu.RLock() + defer r.mu.RUnlock() + names := make([]string, 0, len(r.byName)) + for name := range r.byName { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// defaultRegistry is the production registry. Resolvers are added to it from +// package init() via RegisterDefault; DefaultRegistry freezes it on first read, +// which happens before the kernel and the xDS client start. +var defaultRegistry = NewRegistry() + +// RegisterDefault adds a resolver to the production registry. Intended for +// package init() only — it panics on a duplicate name or after the registry has +// been frozen, both of which are build-time mistakes rather than runtime states. +func RegisterDefault(res Resolver) { + defaultRegistry.MustRegister(res) +} + +// DefaultRegistry freezes and returns the production registry. Callers receive the +// read-only interface: nothing outside this package can register into it after +// startup, and tests that need their own resolvers build an independent Registry +// with NewRegistry instead of mutating this one. +func DefaultRegistry() ResolverRegistry { + defaultRegistry.Freeze() + return defaultRegistry } func init() { - // Register built-in resolvers - Register(&RouteKeyResolver{}) + // The identity resolver every kind shipping today prepares to. It is a real + // registry entry, not a special case: PrepareRoute normalises an empty + // resolver_name to this name and prepares it like any other. + RegisterDefault(&RouteKeyResolver{}) +} + +// ChainKeyFor composes the policy chain key for one operation. +// +// The construction itself lives in common/chainkey, not here: the controller emits +// chains under the same key and cannot import this package (separate module, and this +// one is internal/). This is a re-export so resolver and kernel code reads naturally, +// not a second implementation. +// +// vhost carries the routing partition. A header-match discriminator is deliberately +// not part of the key; a caller that can produce two routes differing only by header +// match must reject that configuration instead. +func ChainKeyFor(apiID, vhost, operation string) string { + return chainkey.For(apiID, vhost, operation) +} + +// NormalizeResolutionError guarantees the kernel always has a typed failure to +// classify. A resolver that returns a *ResolutionError keeps its classification; +// anything else becomes FailureInternal, which renders as the generic sterile response +// and never reaches the client. +func NormalizeResolutionError(err error) *ResolutionError { + if err == nil { + return nil + } + if re, ok := errors.AsType[*ResolutionError](err); ok { + out := *re + if out.Kind == "" { + out.Kind = FailureInternal + } + return &out + } + return &ResolutionError{Kind: FailureInternal, Cause: err} } diff --git a/gateway/gateway-runtime/policy-engine/internal/resolver/resolver_test.go b/gateway/gateway-runtime/policy-engine/internal/resolver/resolver_test.go index 4d4b700ed..a0e6aa84f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/resolver/resolver_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/resolver/resolver_test.go @@ -19,96 +19,838 @@ package resolver import ( + "context" + "encoding/json" + "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestRouteKeyResolver_Name verifies the resolver reports the correct name. -func TestRouteKeyResolver_Name(t *testing.T) { +// ─── Fake resolvers ────────────────────────────────────────────────────────── +// +// These exist so the resolution seam is fully testable before any real +// multiplexed kind ships: the production binary registers only "route-key", so +// nothing below is reachable in production. + +// fakeResolver is a factory whose prepared resolvers report whatever the test +// configured, per route. prepare is optional; without it the factory prepares a +// resolver that returns `resolution`/`err` for every request. +type fakeResolver struct { + name string + reqs RequestRequirements + resolution Resolution + err error + + // prepare, when set, overrides the default and receives the route's config, so a + // test can prove two routes prepared by one factory are independent. + prepare func(ResolverRouteConfig) (PreparedResolver, error) + + prepareCalls int + lastConfig ResolverRouteConfig +} + +func (f *fakeResolver) Name() string { return f.name } + +func (f *fakeResolver) Prepare(cfg ResolverRouteConfig) (PreparedResolver, error) { + f.prepareCalls++ + f.lastConfig = cfg + if f.prepare != nil { + return f.prepare(cfg) + } + return &fakePrepared{reqs: f.reqs, resolution: f.resolution, err: f.err}, nil +} + +// fakePrepared is one prepared route. +type fakePrepared struct { + reqs RequestRequirements + resolution Resolution + err error + calls int + lastView RequestView +} + +func (f *fakePrepared) Requirements() RequestRequirements { return f.reqs } + +func (f *fakePrepared) Resolve(_ context.Context, view RequestView) (Resolution, error) { + f.calls++ + f.lastView = view + return f.resolution, f.err +} + +// fakeStatic is a prepared resolver whose answer is fixed at ingest. +type fakeStatic struct { + fakePrepared + static Resolution +} + +func (f *fakeStatic) StaticResolution() Resolution { return f.static } + +func registryWith(t *testing.T, resolvers ...Resolver) *Registry { + t.Helper() + reg := NewRegistry() + for _, r := range resolvers { + require.NoError(t, reg.Register(r)) + } + reg.Freeze() + return reg +} + +// prepareWith prepares one route through reg, failing the test if it cannot. +func prepareWith(t *testing.T, reg ResolverRegistry, cfg ResolverRouteConfig) *PreparedRoute { + t.Helper() + pr, err := PrepareRoute(reg, cfg) + require.NoError(t, err) + return pr +} + +// fakeChain stands in for the kernel's policy chain: the binder is generic over the +// chain type and only ever checks whether the accessor produced one. +type fakeChain struct{ key string } + +// chainsPresent builds the chain accessor Bind selects against, recording every key it +// was asked for so a test can assert how many lookups a binding actually cost. +type chainStore struct { + present map[string]struct{} + lookedUp []string +} + +func chainsPresent(keys ...string) *chainStore { + s := &chainStore{present: make(map[string]struct{}, len(keys))} + for _, k := range keys { + s.present[k] = struct{}{} + } + return s +} + +func (s *chainStore) get(key string) *fakeChain { + s.lookedUp = append(s.lookedUp, key) + if _, ok := s.present[key]; !ok { + return nil + } + return &fakeChain{key: key} +} + +// noChains is the accessor for a route whose partition has no chains at all. +func noChains(string) *fakeChain { return nil } + +// ─── Identity resolver ─────────────────────────────────────────────────────── + +func TestRouteKeyResolver_PreparesAStaticDirectResolution(t *testing.T) { r := &RouteKeyResolver{} assert.Equal(t, "route-key", r.Name()) + + prepared, err := r.Prepare(ResolverRouteConfig{ + RouteKey: "GET|/api/v1/users|example.com", + CanonicalChainKey: "GET|/api/v1/users|example.com", + }) + require.NoError(t, err) + assert.Equal(t, RequestRequirements{Body: BodyNotRequired}, prepared.Requirements()) + assert.False(t, prepared.Requirements().BuffersBody(), "an identity route must never buffer a body") + + static, ok := prepared.(StaticPreparedResolver) + require.True(t, ok, "route-key must be static so the request path never calls Resolve") + res := static.StaticResolution() + assert.Equal(t, TargetDirectRoute, res.Target) + assert.Equal(t, "GET|/api/v1/users|example.com", res.ChainKey) } -// TestRouteKeyResolver_Requirements verifies the resolver reports no buffering or headers needed. -func TestRouteKeyResolver_Requirements(t *testing.T) { - r := &RouteKeyResolver{} - reqs := r.Requirements() - assert.False(t, reqs.BufferBody) - assert.False(t, reqs.Headers) +// The canonical key is read from the config, never rebuilt from the route key: that is +// the seam that keeps a later move to a separate key namespace a controller-only change. +func TestRouteKeyResolver_UsesTheCanonicalKeyNotTheRouteKey(t *testing.T) { + prepared, err := (&RouteKeyResolver{}).Prepare(ResolverRouteConfig{ + RouteKey: "POST|/op-one|example.com", + CanonicalChainKey: "operation-chain-key", + }) + require.NoError(t, err) + assert.Equal(t, "operation-chain-key", + prepared.(StaticPreparedResolver).StaticResolution().ChainKey) } -// TestRouteKeyResolver_Resolve verifies the resolver returns the route key unchanged. -func TestRouteKeyResolver_Resolve(t *testing.T) { - r := &RouteKeyResolver{} +// Ingest applies the older-controller fallback to the route key, exactly once. Applying +// it a second time here would create a second place for the two to disagree, so an +// empty effective key is a wiring fault and the route is refused. +func TestRouteKeyResolver_RefusesToReapplyTheFallback(t *testing.T) { + _, err := (&RouteKeyResolver{}).Prepare(ResolverRouteConfig{RouteKey: "GET|/pets|h"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "effective chain key") +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +func TestRegistry_RegisterAndGet(t *testing.T) { + reg := NewRegistry() + r := &fakeResolver{name: "fake"} + require.NoError(t, reg.Register(r)) + + got, ok := reg.Get("fake") + require.True(t, ok) + assert.Same(t, r, got) + + _, ok = reg.Get("missing") + assert.False(t, ok) +} + +// A duplicate name means two resolvers answer to one wire value; that is always a +// build mistake, never something to resolve at runtime by picking one. +func TestRegistry_RejectsDuplicateRegistration(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.Register(&fakeResolver{name: "fake"})) + + err := reg.Register(&fakeResolver{name: "fake"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} + +func TestRegistry_RejectsNilAndEmptyName(t *testing.T) { + reg := NewRegistry() + require.Error(t, reg.Register(nil)) + require.Error(t, reg.Register(&fakeResolver{name: ""})) +} + +func TestRegistry_FreezeBlocksRegistration(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.Register(&fakeResolver{name: "before"})) + reg.Freeze() + assert.True(t, reg.Frozen()) + + require.Error(t, reg.Register(&fakeResolver{name: "after"})) + + _, ok := reg.Get("after") + assert.False(t, ok) +} + +func TestRegistry_NamesAreSorted(t *testing.T) { + reg := registryWith(t, &fakeResolver{name: "zeta"}, &fakeResolver{name: "alpha"}, &fakeResolver{name: "mid"}) + assert.Equal(t, []string{"alpha", "mid", "zeta"}, reg.Names()) +} + +// The production registry ships identity-only, so nothing a resolver could read out +// of a request is reachable in the shipped binary yet. +func TestDefaultRegistry_IsIdentityOnlyAndFrozen(t *testing.T) { + def := DefaultRegistry() + assert.Equal(t, []string{RouteKeyResolverName}, def.Names()) + + r, ok := def.Get(RouteKeyResolverName) + require.True(t, ok) + assert.Equal(t, RouteKeyResolverName, r.Name()) + + assert.True(t, defaultRegistry.Frozen(), "DefaultRegistry must freeze the production registry") +} + +// A test registry must not be able to leak a resolver into the production one. +func TestIndependentRegistryDoesNotAffectDefault(t *testing.T) { + _ = registryWith(t, &fakeResolver{name: "test-only"}) + + _, ok := DefaultRegistry().Get("test-only") + assert.False(t, ok, "a resolver registered in a test registry must not appear in the production registry") + assert.Equal(t, []string{RouteKeyResolverName}, DefaultRegistry().Names()) +} + +// ─── PrepareRoute ──────────────────────────────────────────────────────────── + +// An empty resolver_name is identity, normalised once here so nothing downstream has +// to know that "" and "route-key" mean the same thing. +func TestPrepareRoute_NormalizesEmptyResolverName(t *testing.T) { + for _, name := range []string{"", RouteKeyResolverName} { + t.Run(fmt.Sprintf("resolver_name=%q", name), func(t *testing.T) { + pr := prepareWith(t, DefaultRegistry(), ResolverRouteConfig{ + RouteKey: "GET|/pets|example.com", + CanonicalChainKey: "GET|/pets|example.com", + ResolverName: name, + }) + assert.Equal(t, RouteKeyResolverName, pr.ResolverName) + assert.True(t, pr.IsStatic()) + }) + } +} + +func TestPrepareRoute_CapturesThePartitionAndConfig(t *testing.T) { + fake := &fakeResolver{name: "fake"} + cfg := ResolverRouteConfig{ + RouteKey: "POST|/rpc|api.example.com", + CanonicalChainKey: "POST|/rpc|api.example.com", + ResolverName: "fake", + APIID: "api-1", + Vhost: "api.example.com", + APIContext: "/agent/v1", + Method: "POST", + Path: "/rpc", + ResolverConfig: json.RawMessage(`{"transport":"jsonrpc"}`), + } + + pr := prepareWith(t, registryWith(t, fake), cfg) + assert.Equal(t, 1, fake.prepareCalls, "Prepare runs once per route, at ingest") + assert.Equal(t, cfg, fake.lastConfig, "the whole route config reaches Prepare unchanged") + assert.Equal(t, "api-1", pr.APIID) + assert.Equal(t, "api.example.com", pr.Vhost) + assert.Equal(t, "POST|/rpc|api.example.com", pr.DirectChainKey) +} + +func TestPrepareRoute_UnknownResolverIsClassified(t *testing.T) { + _, err := PrepareRoute(registryWith(t, &fakeResolver{name: "known"}), ResolverRouteConfig{ + RouteKey: "r", + ResolverName: "not-registered", + }) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureUnknownResolver, re.Kind) +} + +// A nil registry is identity-only: a partially-wired server keeps serving every kind +// that resolves by route key rather than dropping every route on the gateway, but no +// protocol resolver is ever substituted for another. +func TestPrepareRoute_NilRegistryIsIdentityOnly(t *testing.T) { + pr, err := PrepareRoute(nil, ResolverRouteConfig{ + RouteKey: "GET|/pets|h", CanonicalChainKey: "GET|/pets|h", + }) + require.NoError(t, err) + assert.True(t, pr.IsStatic()) + + _, err = PrepareRoute(nil, ResolverRouteConfig{RouteKey: "r", ResolverName: "fake-protocol"}) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureUnknownResolver, re.Kind) +} + +func TestPrepareRoute_ResolverErrorIsReturned(t *testing.T) { + fake := &fakeResolver{name: "fake", prepare: func(ResolverRouteConfig) (PreparedResolver, error) { + return nil, errors.New("bad schema") + }} + _, err := PrepareRoute(registryWith(t, fake), ResolverRouteConfig{ResolverName: "fake"}) + require.Error(t, err) + assert.EqualError(t, err, "bad schema") + + var re *ResolutionError + assert.False(t, errors.As(err, &re), + "a resolver's own failure must not be mistaken for an unknown resolver") +} + +// A factory returning (nil, nil) would otherwise store a route whose every request +// dereferences nil on the hot path. +func TestPrepareRoute_RejectsANilPreparedResolver(t *testing.T) { + fake := &fakeResolver{name: "fake", prepare: func(ResolverRouteConfig) (PreparedResolver, error) { + return nil, nil + }} + _, err := PrepareRoute(registryWith(t, fake), ResolverRouteConfig{ResolverName: "fake", RouteKey: "r"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil resolver") +} + +// An unrecognised body requirement is refused rather than guessed at. Guessing the +// lenient way — "not required" — would let a resolver that asked for the body run without +// one and select a chain from nothing. +func TestPrepareRoute_RejectsAnUnknownBodyRequirement(t *testing.T) { + factory := &fakeResolver{name: "future", prepare: func(ResolverRouteConfig) (PreparedResolver, error) { + return &fakePrepared{reqs: RequestRequirements{Body: BodyRequirement(7)}}, nil + }} + _, err := PrepareRoute(registryWith(t, factory), ResolverRouteConfig{ + ResolverName: "future", RouteKey: "POST|/rpc|h", CanonicalChainKey: "POST|/rpc|h", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unrecognised body requirement") + assert.Contains(t, err.Error(), "unknown(7)") +} + +// Whatever the requirement, an unrecognised one must never read as "needs nothing": the +// helper the request path uses is conservative, so even if such a value reached it the +// body would be provided rather than withheld. +func TestBuffersBody_TreatsAnUnknownRequirementAsNeedingTheBody(t *testing.T) { + assert.False(t, RequestRequirements{}.BuffersBody(), "the zero value needs no body") + assert.False(t, RequestRequirements{Body: BodyNotRequired}.BuffersBody()) + assert.True(t, RequestRequirements{Body: BodyBuffered}.BuffersBody()) + assert.True(t, RequestRequirements{Body: BodyRequirement(7)}.BuffersBody(), + "an unknown requirement must not silently mean the body can be withheld") +} + +// A resolver cannot be static and also need the request. The static branch is taken +// before the body-buffering check, so the declared requirement would be skipped silently +// — the request would resolve from a stored answer while the resolver believed it was +// being handed a body. +func TestPrepareRoute_RejectsAStaticResolverThatNeedsTheRequest(t *testing.T) { tests := []struct { - name string - routeKey string + name string + reqs RequestRequirements }{ - {"simple route key", "GET|/api/v1/users|example.com"}, - {"empty route key", ""}, - {"route key with special chars", "POST|/path/{id}|host.local:8080"}, + {"buffered body", RequestRequirements{Body: BodyBuffered}}, + {"headers", RequestRequirements{Headers: true}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := ResolverContext{RouteKey: tt.routeKey} - got, err := r.Resolve(ctx) - require.NoError(t, err) - assert.Equal(t, tt.routeKey, got) + factory := &fakeResolver{name: "contradictory", prepare: func(ResolverRouteConfig) (PreparedResolver, error) { + return &fakeStatic{ + fakePrepared: fakePrepared{reqs: tt.reqs}, + static: Resolution{ + Target: TargetDirectRoute, ChainKey: "POST|/rpc|h", + }, + }, nil + }} + + _, err := PrepareRoute(registryWith(t, factory), ResolverRouteConfig{ + ResolverName: "contradictory", RouteKey: "POST|/rpc|h", CanonicalChainKey: "POST|/rpc|h", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "static resolution needs nothing from the request") }) } } -// TestRegister_And_Get verifies that resolvers can be registered and retrieved. -func TestRegister_And_Get(t *testing.T) { - // Clean up: save and restore Registry state - original := Registry - Registry = map[string]PolicyChainResolver{} - defer func() { Registry = original }() +// route-key is the shape a static resolver must have: it declares nothing, so nothing it +// declared can be skipped. +func TestRouteKeyResolver_DeclaresNoRequestRequirements(t *testing.T) { + pr := prepareWith(t, DefaultRegistry(), ResolverRouteConfig{ + RouteKey: "GET|/pets|h", CanonicalChainKey: "GET|/pets|h", + }) + require.True(t, pr.IsStatic()) + assert.Equal(t, RequestRequirements{}, pr.Requirements) +} + +// The point of preparing per route: one factory, two routes, different requirements. +// This is what lets one resolver read the body on a route that multiplexes operations +// while needing nothing at all on a route whose operation is fixed at deploy time. +func TestPrepareRoute_RequirementsArePerRouteNotPerFactory(t *testing.T) { + factory := &fakeResolver{name: "fake-protocol", prepare: func(cfg ResolverRouteConfig) (PreparedResolver, error) { + if cfg.Path == "/rpc" { + return &fakePrepared{reqs: RequestRequirements{Body: BodyBuffered}}, nil + } + return &fakeStatic{ + static: Resolution{Target: TargetOperation, ChainKey: ChainKeyFor("api-1", "h", "OperationOne")}, + }, nil + }} + reg := registryWith(t, factory) + + multiplexed := prepareWith(t, reg, ResolverRouteConfig{ResolverName: "fake-protocol", Path: "/rpc", APIID: "api-1", Vhost: "h"}) + perOperation := prepareWith(t, reg, ResolverRouteConfig{ResolverName: "fake-protocol", Path: "/op-one", APIID: "api-1", Vhost: "h"}) + + assert.True(t, multiplexed.Requirements.BuffersBody(), + "a route carrying many operations can only know which one from the body") + assert.False(t, multiplexed.IsStatic()) + + assert.False(t, perOperation.Requirements.BuffersBody(), + "a route dedicated to one operation knows it at deploy time") + assert.True(t, perOperation.IsStatic(), "and therefore never runs on the request path") +} - mock := &RouteKeyResolver{} - Register(mock) +// ─── Bind: direct targets ──────────────────────────────────────────────────── - got, err := Get("route-key") +func TestBind_DirectTargetSelectsTheRoutesOwnChain(t *testing.T) { + pr := prepareWith(t, DefaultRegistry(), ResolverRouteConfig{ + RouteKey: "GET|/pets|h", + CanonicalChainKey: "GET|/pets|h", + }) + + store := chainsPresent("GET|/pets|h") + bound, chain, err := BindStatic(pr, store.get) require.NoError(t, err) - assert.Equal(t, mock, got) + assert.Equal(t, "GET|/pets|h", bound.ChainKey) + + // The binding returns the chain it selected rather than reporting that one exists, + // so this is the only lookup the request needs — and no eviction can slip between a + // probe and a read. + require.NotNil(t, chain) + assert.Equal(t, "GET|/pets|h", chain.key) + assert.Equal(t, []string{"GET|/pets|h"}, store.lookedUp, + "the static fast path must cost exactly one chain lookup") } -// TestGet_NotFound verifies that Get returns an error for unknown resolvers. -func TestGet_NotFound(t *testing.T) { - original := Registry - Registry = map[string]PolicyChainResolver{} - defer func() { Registry = original }() +// The static fast path does no structural work per request: PrepareRoute validated the +// resolution once, so binding is a lookup and a struct copy. A resolution that would not +// validate never reaches this point — the route is refused at preparation. +func TestBindStatic_DoesNoPerRequestValidation(t *testing.T) { + // A resolver whose static resolution names a chain outside its own route. + factory := &fakeResolver{name: "bad-static", prepare: func(ResolverRouteConfig) (PreparedResolver, error) { + return &fakeStatic{static: Resolution{ + Target: TargetDirectRoute, + ChainKey: "GET|/admin|h", + }}, nil + }} - _, err := Get("nonexistent-resolver") - require.Error(t, err) - assert.Contains(t, err.Error(), "nonexistent-resolver") + _, err := PrepareRoute(registryWith(t, factory), ResolverRouteConfig{ + ResolverName: "bad-static", + RouteKey: "GET|/pets|h", + CanonicalChainKey: "GET|/pets|h", + }) + require.Error(t, err, "an invalid static resolution must be caught at ingest, not per request") + assert.Contains(t, err.Error(), "invalid static resolution") + assert.Contains(t, err.Error(), "route's own chain key") + + // It must not be mistaken for an unknown resolver: the resolver was found and it is + // the resolution it produced that is wrong, which ingest reports and counts separately. + var re *ResolutionError + require.ErrorAs(t, err, &re) + assert.Equal(t, FailureInternal, re.Kind) + assert.NotEqual(t, FailureUnknownResolver, re.Kind) +} + +// A direct route with no chain keeps the pre-resolution outcome: the kernel's own +// sterile 500, not a resolution failure the client could read anything into. +func TestBind_DirectTargetWithNoChainIsNotAResolutionFailure(t *testing.T) { + pr := prepareWith(t, DefaultRegistry(), ResolverRouteConfig{ + RouteKey: "GET|/pets|h", + CanonicalChainKey: "GET|/pets|h", + }) + + _, _, err := BindStatic(pr, noChains) + require.ErrorIs(t, err, ErrDirectRouteChainMissing) + + var re *ResolutionError + assert.False(t, errors.As(err, &re), + "this is the existing no-chain path, not a classified resolution failure") +} + +// A resolver claiming a direct target for anything other than this route's own key is +// reaching for a chain it was not given. +func TestBind_DirectTargetRejectsAnyOtherKey(t *testing.T) { + fake := &fakeResolver{name: "fake", resolution: Resolution{ + Target: TargetDirectRoute, + ChainKey: "GET|/admin|h", + }} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", + RouteKey: "GET|/pets|h", + CanonicalChainKey: "GET|/pets|h", + }) + + _, _, err := Bind(pr, fake.resolution, chainsPresent("GET|/admin|h").get) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInternal, re.Kind) + assert.Contains(t, re.Cause.Error(), "route's own chain key") } -// TestInit_RegistersRouteKeyResolver verifies that the init function registers the built-in resolver. -func TestInit_RegistersRouteKeyResolver(t *testing.T) { - // The package init() should have already run; the route-key resolver should be present. - r, err := Get("route-key") +// ─── Bind: operation targets ───────────────────────────────────────────────── + +func TestBind_OperationTarget(t *testing.T) { + const ( + apiID = "api-1" + vhost = "api.example.com" + ) + key := func(operation string) string { return ChainKeyFor(apiID, vhost, operation) } + + tests := []struct { + name string + operation string // the operation embedded in the key, and so the one reported + knownToProtocol bool + chains []string + wantKind FailureKind + }{ + { + name: "the named chain exists", + operation: "OperationOne", + chains: []string{key("OperationOne")}, + }, + { + // Open operation set: the client named a tool that does not exist, so this + // is a 404-shaped failure rather than a deployment error. + name: "no chain, open operation set", + operation: "tools/call:unlisted", + chains: []string{key("tools/call:add")}, + wantKind: FailureUnknownOperation, + }, + { + // Closed operation set: the protocol says this operation exists, so a + // missing chain means the controller built the deployment wrong. Rendering + // it as "unknown operation" would blame the client for a server bug. + name: "no chain, closed operation set", + operation: "OperationOne", + knownToProtocol: true, + chains: []string{key("GetTask")}, + wantKind: FailureChainMissing, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolution := Resolution{ + Target: TargetOperation, + ChainKey: key(tt.operation), + KnownToProtocol: tt.knownToProtocol, + } + fake := &fakeResolver{name: "fake", resolution: resolution} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", + RouteKey: "POST|/rpc|" + vhost, + CanonicalChainKey: "POST|/rpc|" + vhost, + APIID: apiID, + Vhost: vhost, + }) + + store := chainsPresent(tt.chains...) + bound, _, err := Bind(pr, resolution, store.get) + if tt.wantKind == "" { + require.NoError(t, err) + assert.Equal(t, key(tt.operation), bound.ChainKey) + assert.Equal(t, tt.operation, bound.Operation, + "the reported operation is read back out of the key that ran") + assert.Equal(t, []string{key(tt.operation)}, store.lookedUp, + "one resolution names one key, so binding costs exactly one lookup") + return + } + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, tt.wantKind, re.Kind) + assert.Empty(t, bound.ChainKey, "an unresolved request must never bind another chain") + }) + } +} + +// The reported operation always names the chain that actually ran. This is the divergence +// the derivation exists to make unrepresentable: a resolver returning the GetTask chain key +// cannot also report SendMessage, because there is no field with which to say so — so +// telemetry can never name one operation while another operation's authentication, +// authorization and rate limits are the ones enforced. +func TestBind_ReportedOperationAlwaysNamesTheChainThatRan(t *testing.T) { + const ( + apiID = "api-1" + vhost = "api.example.com" + ) + getTask := ChainKeyFor(apiID, vhost, "GetTask") + + // A resolver that meant SendMessage but composed the GetTask key: the mistake is in + // the key, and the key is what runs, so that is what must be reported. + resolution := Resolution{Target: TargetOperation, ChainKey: getTask, KnownToProtocol: true} + fake := &fakeResolver{name: "fake", resolution: resolution} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", RouteKey: "POST|/rpc|" + vhost, + CanonicalChainKey: "POST|/rpc|" + vhost, APIID: apiID, Vhost: vhost, + }) + + bound, chain, err := Bind(pr, resolution, chainsPresent(getTask, ChainKeyFor(apiID, vhost, "SendMessage")).get) require.NoError(t, err) - assert.NotNil(t, r) - assert.Equal(t, "route-key", r.Name()) + require.NotNil(t, chain) + + assert.Equal(t, getTask, bound.ChainKey) + assert.Equal(t, "GetTask", bound.Operation, + "the operation is read out of the key that ran, never asserted alongside it") + assert.Equal(t, getTask, chain.key, + "and the chain executed is the one the reported operation names") } -// TestResolverContext_FieldAccess verifies that ResolverContext fields are accessible. -func TestResolverContext_FieldAccess(t *testing.T) { - headers := map[string][]string{ - "Authorization": {"Bearer token"}, +// A direct route reports no operation: the route chose the chain, not the resolver. It holds +// even when the route is pointed at a composed operation key, which a directly-resolved +// route may be. +func TestBind_DirectRouteReportsNoOperation(t *testing.T) { + const ( + apiID = "api-1" + vhost = "h" + ) + composed := ChainKeyFor(apiID, vhost, "OperationOne") + pr := prepareWith(t, DefaultRegistry(), ResolverRouteConfig{ + RouteKey: "POST|/op-one|" + vhost, CanonicalChainKey: composed, + APIID: apiID, Vhost: vhost, + }) + + bound, _, err := BindStatic(pr, chainsPresent(composed).get) + require.NoError(t, err) + assert.Equal(t, composed, bound.ChainKey) + assert.Empty(t, bound.Operation, + "a direct route identified no operation, and the chain key is already on the span") +} + +// A resolver cannot reach a chain outside its own route's partition, however it +// composed the key. +func TestBind_OperationTargetCannotCrossAPartition(t *testing.T) { + const vhost = "api.example.com" + tests := []struct { + name string + key string + }{ + {"another API's operation", ChainKeyFor("other-api", vhost, "OperationOne")}, + {"another vhost's operation", ChainKeyFor("api-1", "other.example.com", "OperationOne")}, + {"not a composed key at all", "OperationOne"}, + {"composed but with no operation", ChainKeyFor("api-1", vhost, "")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolution := Resolution{Target: TargetOperation, ChainKey: tt.key} + fake := &fakeResolver{name: "fake", resolution: resolution} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", RouteKey: "POST|/rpc|" + vhost, + CanonicalChainKey: "POST|/rpc|" + vhost, APIID: "api-1", Vhost: vhost, + }) + + // The probe says the key exists, so only validation can stop it. + _, _, err := Bind(pr, resolution, chainsPresent(tt.key).get) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInternal, re.Kind) + }) + } +} + +// A malformed key fails the resolution and binds nothing. With one key per resolution +// there is nothing to fall back to, so this is simply a resolver bug — and it must not +// reach for some other chain that happens to exist. +func TestBind_MalformedKeyBindsNothing(t *testing.T) { + const ( + apiID = "api-1" + vhost = "api.example.com" + ) + // A chain that does exist, to prove the failure does not quietly land on it. + existing := ChainKeyFor(apiID, vhost, "tools/call") + resolution := Resolution{ + Target: TargetOperation, + ChainKey: "forged-not-a-composed-key", + } + fake := &fakeResolver{name: "fake", resolution: resolution} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", RouteKey: "POST|/rpc|" + vhost, + CanonicalChainKey: "POST|/rpc|" + vhost, APIID: apiID, Vhost: vhost, + }) + + store := chainsPresent(existing) + bound, chain, err := Bind(pr, resolution, store.get) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInternal, re.Kind) + assert.Empty(t, bound.ChainKey) + assert.Nil(t, chain) + assert.Empty(t, store.lookedUp, "validation fails before any chain is looked up") +} + +// An unset target is rejected rather than defaulting into either set of semantics, so a +// resolver that forgets to set one fails closed. +func TestBind_UnsetTargetIsRejected(t *testing.T) { + resolution := Resolution{ChainKey: "GET|/pets|h"} + fake := &fakeResolver{name: "fake", resolution: resolution} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", RouteKey: "GET|/pets|h", CanonicalChainKey: "GET|/pets|h", + }) + + _, _, err := Bind(pr, resolution, chainsPresent("GET|/pets|h").get) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInternal, re.Kind) + assert.Contains(t, re.Cause.Error(), "unset resolution target") +} + +func TestBind_RejectsAResolutionWithNoChainKey(t *testing.T) { + pr := prepareWith(t, DefaultRegistry(), ResolverRouteConfig{ + RouteKey: "GET|/pets|h", CanonicalChainKey: "GET|/pets|h", + }) + + _, _, err := Bind(pr, Resolution{Target: TargetDirectRoute}, noChains) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInvalidRequest, re.Kind, + "a resolver that identified nothing is a bad request, not an engine fault") +} + +// A nil chain accessor must fail closed rather than resolve or panic: without it the chain +// would appear not to exist, which for an operation target would render as "unknown +// operation" — a client-facing answer to an engine wiring fault. +func TestBind_NilChainAccessorFailsClosedAsInternal(t *testing.T) { + resolution := Resolution{Target: TargetOperation, ChainKey: ChainKeyFor("a", "v", "Op")} + fake := &fakeResolver{name: "fake", resolution: resolution} + pr := prepareWith(t, registryWith(t, fake), ResolverRouteConfig{ + ResolverName: "fake", RouteKey: "r", CanonicalChainKey: "r", APIID: "a", Vhost: "v", + }) + + _, _, err := Bind[fakeChain](pr, resolution, nil) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInternal, re.Kind) +} + +func TestBind_NilPreparedRouteFailsClosed(t *testing.T) { + var pr *PreparedRoute + _, _, err := Bind(pr, Resolution{Target: TargetDirectRoute, ChainKey: "k"}, noChains) + var re *ResolutionError + require.True(t, errors.As(err, &re)) + assert.Equal(t, FailureInternal, re.Kind) +} + +// The convergence property the whole design exists for: two routes reaching the same +// logical operation must select the same chain, whether the operation was read out of the +// request or fixed at deploy time. They converge because both compose from one canonical +// operation name with one shared helper — not because either was pointed at the other's +// key. +func TestBind_RoutesForOneOperationConverge(t *testing.T) { + const ( + apiID = "api-1" + vhost = "api.example.com" + operation = "OperationOne" + ) + composed := ChainKeyFor(apiID, vhost, operation) + probe := chainsPresent(composed) + + // Resolved from the request: many operations share this route. + perRequestResolution := Resolution{ + Target: TargetOperation, ChainKey: composed, KnownToProtocol: true, } - ctx := ResolverContext{ - RouteKey: "GET|/test|host.local", - Headers: headers, - Body: []byte(`{"key":"value"}`), + multiplexed := prepareWith(t, + registryWith(t, &fakeResolver{name: "fake-multiplexed", resolution: perRequestResolution}), + ResolverRouteConfig{ + ResolverName: "fake-multiplexed", RouteKey: "POST|/rpc|" + vhost, + CanonicalChainKey: "POST|/rpc|" + vhost, APIID: apiID, Vhost: vhost, + }) + fromPerRequest, _, err := Bind(multiplexed, perRequestResolution, probe.get) + require.NoError(t, err) + + // Fixed at ingest: this route serves one operation, named in its configuration. + staticResolution := Resolution{ + Target: TargetOperation, ChainKey: composed, KnownToProtocol: true, } + perOperation := prepareWith(t, + registryWith(t, &fakeResolver{name: "fake-per-operation", prepare: func(ResolverRouteConfig) (PreparedResolver, error) { + return &fakeStatic{static: staticResolution}, nil + }}), + ResolverRouteConfig{ + ResolverName: "fake-per-operation", RouteKey: "POST|/op-one|" + vhost, + CanonicalChainKey: "POST|/op-one|" + vhost, APIID: apiID, Vhost: vhost, + }) + require.True(t, perOperation.IsStatic()) + fromStatic, _, err := BindStatic(perOperation, probe.get) + require.NoError(t, err) + + assert.Equal(t, fromPerRequest.ChainKey, fromStatic.ChainKey) + assert.Equal(t, composed, fromPerRequest.ChainKey) +} + +// ─── Errors ────────────────────────────────────────────────────────────────── + +func TestNormalizeResolutionError(t *testing.T) { + assert.Nil(t, NormalizeResolutionError(nil)) + + // A typed error with no kind is still classified rather than left blank. + re := NormalizeResolutionError(&ResolutionError{}) + assert.Equal(t, FailureInternal, re.Kind) + + // A typed error keeps its own classification. + re = NormalizeResolutionError(&ResolutionError{Kind: FailureParse}) + assert.Equal(t, FailureParse, re.Kind) + + // Normalizing must not mutate the resolver's error value. + original := &ResolutionError{Kind: FailureParse} + _ = NormalizeResolutionError(original) + assert.Equal(t, FailureParse, original.Kind) + + // A wrapped typed error keeps its classification. + re = NormalizeResolutionError(fmt.Errorf("wrapped: %w", &ResolutionError{Kind: FailureUnknownOperation})) + assert.Equal(t, FailureUnknownOperation, re.Kind) +} + +// An untyped resolver error must not be guessed at: it becomes FailureInternal, +// which renders generically and never reaches the client. +func TestNormalizeResolutionError_UntypedBecomesInternal(t *testing.T) { + re := NormalizeResolutionError(errors.New("boom")) + assert.Equal(t, FailureInternal, re.Kind) + assert.EqualError(t, re.Cause, "boom", "the cause is kept for the internal log only") +} + +func TestRouteResolution_IsIdentity(t *testing.T) { + assert.True(t, (&RouteResolution{}).IsIdentity()) + assert.True(t, (&RouteResolution{ResolverName: RouteKeyResolverName}).IsIdentity()) + assert.False(t, (&RouteResolution{ResolverName: "fake-multiplexed"}).IsIdentity()) +} - assert.Equal(t, "GET|/test|host.local", ctx.RouteKey) - assert.Equal(t, []string{"Bearer token"}, ctx.Headers["Authorization"]) - assert.Equal(t, []byte(`{"key":"value"}`), ctx.Body) +func TestTargetKind_String(t *testing.T) { + assert.Equal(t, "direct-route", TargetDirectRoute.String()) + assert.Equal(t, "operation", TargetOperation.String()) + assert.Equal(t, "invalid", TargetInvalid.String()) } diff --git a/gateway/gateway-runtime/policy-engine/internal/resolver/route.go b/gateway/gateway-runtime/policy-engine/internal/resolver/route.go new file mode 100644 index 000000000..18cf2f59a --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/resolver/route.go @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package resolver + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/wso2/api-platform/common/chainkey" +) + +// ErrDirectRouteChainMissing reports that a direct-route resolution named a chain that +// does not exist. It is deliberately not a *ResolutionError: this is the pre-existing +// "route has no policy chain" condition, whose response is the kernel's own sterile +// 500, and classifying it as a resolution failure would change what every kind +// shipping today returns. +var ErrDirectRouteChainMissing = errors.New("resolver: no policy chain for this route") + +// ResolverRouteConfig is one route's static resolution configuration, as delivered by +// the controller and normalised by xDS ingest. It is deployment-scoped data, not a +// request context — the name is deliberate: context.Context remains the +// cancellation/deadline carrier passed to request-time Resolve. +type ResolverRouteConfig struct { + // RouteKey is the Envoy route name (METHOD|fullPath|vhost). + RouteKey string + + // CanonicalChainKey is the effective direct chain key, meaningful only for a + // directly-resolved route. Ingest applies the older-controller fallback to RouteKey + // when the wire field is absent, so it is never empty and every consumer — a + // Prepare implementation and the binder's direct-target check — reads the same + // value from one place. + // + // A protocol-resolved route carries no key on the wire: it derives one from + // ResolverConfig, so this holds the fallback value and nothing reads it. + CanonicalChainKey string + + // ResolverName is the effective resolver name, already normalised: never empty. + ResolverName string + + // APIID and Vhost are the route's partition, and the composition inputs a prepared + // resolver captures for ChainKeyFor. APIContext is here because a path-based + // resolver needs it to strip the API's prefix before matching. + APIID string + Vhost string + APIContext string + + Method string // upper-cased at ingest (GO-AUTH-006), like RequestView.Method + Path string + + // ResolverConfig is opaque per-route resolver configuration. + ResolverConfig json.RawMessage +} + +// RouteResolution is a route's resolution configuration as it arrived over xDS, plus +// the prepared resolver built from it. It is embedded in the kernel's RouteConfig so +// the fields are reachable directly off the route (rc.CanonicalChainKey, rc.Prepared, +// …) without copying the struct per request. +type RouteResolution struct { + // RouteKey is the Envoy route name (METHOD|fullPath|vhost). + RouteKey string + + // CanonicalChainKey is the effective direct chain key for this route, as composed + // by the controller. It equals RouteKey for every kind shipping today, but it is + // always read from this field and never reconstructed: that is what lets a directly + // resolved route be pointed at a composed operation key without a wire change. + CanonicalChainKey string + + // ResolverName is the effective resolver for this route. Empty and + // RouteKeyResolverName both mean identity. + ResolverName string + + // ResolverConfig is opaque per-route resolver configuration, passed to Prepare. + ResolverConfig json.RawMessage + + // Prepared is the immutable result of preparing this route. Nil only for a route + // that failed preparation, which ingest drops rather than serving. + Prepared *PreparedRoute +} + +// IsIdentity reports whether this route resolves its chain by route identity, with +// no request inspection. +func (r *RouteResolution) IsIdentity() bool { + return r.ResolverName == "" || r.ResolverName == RouteKeyResolverName +} + +// PreparedRoute is what the kernel stores per route: the immutable prepared resolver, +// the requirements it declared, and the static inputs the binder validates against. +// Everything here is fixed at ingest — nothing in it is derived per request. +type PreparedRoute struct { + // ResolverName is the effective (normalised) resolver name, for logs and metrics. + ResolverName string + + Resolver PreparedResolver + Requirements RequestRequirements + + // StaticResolution is non-nil when the prepared resolver implements + // StaticPreparedResolver. Its presence is what lets the request path skip building + // a RequestView and calling Resolve at all. + StaticResolution *Resolution + + // DirectChainKey is the effective CanonicalChainKey for this route, and + // APIID/Vhost are its partition. Held here so key validation compares against + // captured values rather than re-deriving anything on the request path. + DirectChainKey string + APIID string + Vhost string +} + +// PrepareRoute normalises the resolver name, looks the factory up and prepares this +// one route. +// +// An unknown resolver is reported as a *ResolutionError of kind +// FailureUnknownResolver so the caller can tell it apart from a resolver's own +// preparation failure; every other error is the resolver's, returned as-is. +func PrepareRoute(reg ResolverRegistry, cfg ResolverRouteConfig) (*PreparedRoute, error) { + if cfg.ResolverName == "" { + cfg.ResolverName = RouteKeyResolverName + } + + factory, ok := lookupFactory(reg, cfg.ResolverName) + if !ok { + return nil, &ResolutionError{Kind: FailureUnknownResolver} + } + + prepared, err := factory.Prepare(cfg) + if err != nil { + return nil, err + } + if prepared == nil { + return nil, fmt.Errorf("resolver %q prepared a nil resolver for route %q", + cfg.ResolverName, cfg.RouteKey) + } + + static, isStatic := prepared.(StaticPreparedResolver) + reqs := prepared.Requirements() + if err := validateRequirements(reqs, isStatic); err != nil { + return nil, fmt.Errorf("resolver %q declared unusable requirements for route %q: %w", + cfg.ResolverName, cfg.RouteKey, err) + } + + pr := &PreparedRoute{ + ResolverName: cfg.ResolverName, + Resolver: prepared, + Requirements: reqs, + // Captured once, from the already-resolved effective value, so the binder's + // direct-target check never re-derives the fallback. + DirectChainKey: cfg.CanonicalChainKey, + APIID: cfg.APIID, + Vhost: cfg.Vhost, + } + if isStatic { + resolution := static.StaticResolution() + // Validated here, once, rather than on every request: a static resolution is + // immutable, so an invalid one is a broken route, not a bad request. Catching it + // at ingest drops that one route with a metric instead of failing every request + // to it with a 500 — and it is what lets the request path skip validation + // entirely (see BindStatic). + if err := pr.ValidateResolution(resolution); err != nil { + return nil, fmt.Errorf("resolver %q prepared an invalid static resolution for route %q: %w", + cfg.ResolverName, cfg.RouteKey, err) + } + pr.StaticResolution = &resolution + } + return pr, nil +} + +// validateRequirements refuses requirements the engine cannot honour. +// +// Two ways a resolver can declare something the request path would silently not deliver: +// +// - An unrecognised BodyRequirement. It is not guessed at, because a wrong guess in the +// lenient direction means a resolver that asked for the body selects a chain without +// one. +// - A static resolution paired with any request-dependent requirement. The static branch +// is taken before the body-buffering check, so the declared requirement would be +// skipped with nothing to signal it. A static resolution is by definition complete +// without the request, so needing request data contradicts being static — which makes +// this a construction error rather than a combination to arbitrate. +func validateRequirements(reqs RequestRequirements, isStatic bool) error { + if !reqs.Body.Valid() { + return fmt.Errorf("unrecognised body requirement %s", reqs.Body) + } + if isStatic && reqs != (RequestRequirements{}) { + return fmt.Errorf( + "a static resolution needs nothing from the request, but this one requires headers=%t body=%s", + reqs.Headers, reqs.Body) + } + return nil +} + +// lookupFactory resolves a resolver name, treating a nil registry as identity-only. +// +// Identity is answered from this package rather than the registry, so a partially-wired +// server still serves every kind that resolves by route key instead of dropping every +// route on the gateway. Anything else fails closed: no protocol resolver is ever +// substituted for another. +func lookupFactory(reg ResolverRegistry, name string) (Resolver, bool) { + if reg == nil { + if name == RouteKeyResolverName { + return &RouteKeyResolver{}, true + } + return nil, false + } + return reg.Get(name) +} + +// IsStatic reports whether this route's resolution was fully known at ingest, so the +// request path binds from the stored result without building a RequestView. +func (pr *PreparedRoute) IsStatic() bool { + return pr != nil && pr.StaticResolution != nil +} + +// ValidateResolution checks a resolution's structure against this route: it names a key, +// and that key passes the target's own rules. It touches no chain map, so it is safe to run +// at ingest. +func (pr *PreparedRoute) ValidateResolution(res Resolution) *ResolutionError { + if pr == nil { + return &ResolutionError{ + Kind: FailureInternal, + Cause: errors.New("route was never prepared"), + } + } + if res.ChainKey == "" { + return &ResolutionError{ + Kind: FailureInvalidRequest, + Cause: errors.New("resolution named no chain key"), + } + } + if err := pr.validateResolvedKey(res.Target, res.ChainKey); err != nil { + // FailureInternal renders generically, so the client learns nothing from a + // resolver's own bug. There is nothing to fall back to — one resolution names one + // key — so an invalid key fails the whole resolution. + return &ResolutionError{Kind: FailureInternal, Cause: err} + } + return nil +} + +// Bind validates a resolution and looks up its chain. Use it for a resolution produced per +// request; a route's static resolution was validated at ingest and goes through BindStatic +// instead. +// +// getChain returns the chain itself rather than reporting existence, so binding costs +// exactly one lookup and the selected chain cannot be evicted between a probe and a read. +// It is injected rather than handed to resolvers, so the kernel keeps a single locking +// discipline over its chain map: a resolver never decides whether a chain exists and never +// executes one. +// +// The returned error is nil, ErrDirectRouteChainMissing, or a *ResolutionError — callers +// switch on those three and must not re-normalise it. +func Bind[C any](pr *PreparedRoute, res Resolution, getChain func(string) *C) (BoundResolution, *C, error) { + if err := pr.ValidateResolution(res); err != nil { + return BoundResolution{}, nil, err + } + return selectChain(res, getChain) +} + +// BindStatic looks up the chain for this route's static resolution, the one fixed at +// ingest. It performs no validation: PrepareRoute already validated that resolution and +// it cannot have changed since, so the request path does no structural work at all — one +// chain lookup and a struct copy. +// +// It takes no resolution argument on purpose: the only resolution it can bind is the +// validated one, so there is nothing to pass that could differ from it. +func BindStatic[C any](pr *PreparedRoute, getChain func(string) *C) (BoundResolution, *C, error) { + if pr == nil || pr.StaticResolution == nil { + return BoundResolution{}, nil, &ResolutionError{ + Kind: FailureInternal, + Cause: errors.New("route has no static resolution"), + } + } + return selectChain(*pr.StaticResolution, getChain) +} + +// selectChain looks up the resolution's chain and builds the bound result, or classifies +// why no chain was found. +func selectChain[C any](res Resolution, getChain func(string) *C) (BoundResolution, *C, error) { + if getChain == nil { + // Without an accessor the chain would appear not to exist, which for an operation + // target would render as "unknown operation" — a client-facing answer to what is + // really an engine wiring fault. + return BoundResolution{}, nil, &ResolutionError{ + Kind: FailureInternal, + Cause: errors.New("no chain accessor was provided"), + } + } + + if chain := getChain(res.ChainKey); chain != nil { + return BoundResolution{ + ChainKey: res.ChainKey, + Operation: operationFor(res.Target, res.ChainKey), + }, chain, nil + } + + // A direct route keeps the pre-resolution outcome: the kernel's own sterile 500 for a + // route with no chain. + if res.Target == TargetDirectRoute { + return BoundResolution{}, nil, ErrDirectRouteChainMissing + } + + // A known protocol operation missing its generated chain is deployment or xDS skew; + // an unknown one is the client naming something that does not exist. + kind := FailureUnknownOperation + if res.KnownToProtocol { + kind = FailureChainMissing + } + return BoundResolution{}, nil, &ResolutionError{Kind: kind} +} + +// validateResolvedKey enforces the target boundary. A resolver composes its own key; this +// is what stops a composition bug or a hostile identifier from reaching a chain that +// belongs to a different route, API or vhost. Callers check for an empty key first. +func (pr *PreparedRoute) validateResolvedKey(target TargetKind, key string) error { + switch target { + case TargetDirectRoute: + // Compared against the value captured at preparation, never re-derived: one + // fallback site, so a second one cannot disagree with it. + if key != pr.DirectChainKey { + return errors.New("direct target must be the route's own chain key") + } + return nil + case TargetOperation: + apiID, vhost, operation, ok := chainkey.Split(key) + if !ok || operation == "" { + return errors.New("operation target is not a well-formed composed key") + } + if apiID != pr.APIID || vhost != pr.Vhost { + return errors.New("operation target crosses an API or routing partition") + } + return nil + default: + return fmt.Errorf("unset resolution target (%s)", target) + } +} + +// operationFor reports the canonical operation the selected chain serves, read back out of +// the key that was validated and looked up. +// +// Deriving it here is what makes the reported operation and the executed chain the same +// fact: a resolver has no field with which to claim a different one, so telemetry cannot +// name one operation while another operation's policies run. +// +// A direct route reports nothing. Its key is the route's own — not necessarily a composed +// one at all — and the resolver identified no operation, so attributing one to it would +// misreport who chose the chain. +func operationFor(target TargetKind, key string) string { + if target != TargetOperation { + return "" + } + _, _, operation, _ := chainkey.Split(key) // validated before the lookup + return operation +} diff --git a/gateway/gateway-runtime/policy-engine/internal/resolver/routekey.go b/gateway/gateway-runtime/policy-engine/internal/resolver/routekey.go index eb848c95e..6b149078f 100644 --- a/gateway/gateway-runtime/policy-engine/internal/resolver/routekey.go +++ b/gateway/gateway-runtime/policy-engine/internal/resolver/routekey.go @@ -18,20 +18,55 @@ package resolver -// RouteKeyResolver trivially returns the route key as the policy chain key. -// Used by RestAPI, LLM Provider, and LLM Proxy kinds where each route has -// exactly one policy chain, keyed by the same route name. +import ( + "context" + "errors" +) + +// RouteKeyResolver is the identity resolver: the request carries no operation +// identifier of its own, so the route's canonical chain key is the answer. Used by +// RestApi, WebSubApi, Mcp-as-shipped-today, LlmProvider and LlmProxy, where each route +// has exactly one policy chain. +// +// It is a real registry entry rather than a special case in the binding path, and it +// still costs nothing per request: the resolution it prepares is entirely static, so +// the kernel binds from the stored result without building a request view or calling +// Resolve. type RouteKeyResolver struct{} -func (r *RouteKeyResolver) Name() string { return "route-key" } +// Name returns the wire value for identity resolution. +func (*RouteKeyResolver) Name() string { return RouteKeyResolverName } -func (r *RouteKeyResolver) Requirements() ResolverRequirements { - return ResolverRequirements{ - BufferBody: false, - Headers: false, +// Prepare captures the route's effective chain key. +// +// It deliberately does not re-apply the fallback to RouteKey: ingest already resolved +// the effective value, so an empty one here means the ingest layer is broken, not that +// this route wants its route key. Applying it a second time would create a second +// place for the two to disagree. +func (*RouteKeyResolver) Prepare(cfg ResolverRouteConfig) (PreparedResolver, error) { + if cfg.CanonicalChainKey == "" { + return nil, errors.New("route-key resolver requires an effective chain key") } + return &preparedRouteKey{key: cfg.CanonicalChainKey}, nil +} + +// preparedRouteKey is one identity route, holding only the key its requests bind to. +type preparedRouteKey struct { + key string +} + +// Requirements reports that nothing about the request is needed. +func (*preparedRouteKey) Requirements() RequestRequirements { + return RequestRequirements{Body: BodyNotRequired} +} + +// StaticResolution is the whole of this resolver's work, done once at ingest. +func (r *preparedRouteKey) StaticResolution() Resolution { + return Resolution{Target: TargetDirectRoute, ChainKey: r.key} } -func (r *RouteKeyResolver) Resolve(ctx ResolverContext) (string, error) { - return ctx.RouteKey, nil +// Resolve returns the same static resolution. Reached only by a caller that ignores +// StaticPreparedResolver; the kernel does not, so this never runs on the request path. +func (r *preparedRouteKey) Resolve(context.Context, RequestView) (Resolution, error) { + return r.StaticResolution(), nil } diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go index e8ff4572b..20a376ebe 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client.go @@ -36,11 +36,13 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" ) // Client is the xDS client that subscribes to policy chain configurations via ADS @@ -62,6 +64,11 @@ type Client struct { routeConfigVersion string currentNonce string + // resolvers is the frozen operation-resolver registry this runtime supports. + // Advertised in Node.Metadata on every discovery request so the control plane + // can withhold routes whose resolver this binary does not have. + resolvers resolver.ResolverRegistry + // Lifecycle management ctx context.Context cancel context.CancelFunc @@ -70,8 +77,10 @@ type Client struct { reconnectCh chan struct{} } -// NewClient creates a new xDS client -func NewClient(config *Config, k *kernel.Kernel, reg *registry.PolicyRegistry) (*Client, error) { +// NewClient creates a new xDS client. resolvers is the frozen operation-resolver +// registry, used both to validate incoming route resolution config and to +// advertise this runtime's capabilities to the control plane. +func NewClient(config *Config, k *kernel.Kernel, reg *registry.PolicyRegistry, resolvers resolver.ResolverRegistry) (*Client, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } @@ -80,7 +89,8 @@ func NewClient(config *Config, k *kernel.Kernel, reg *registry.PolicyRegistry) ( return &Client{ config: config, - handler: NewResourceHandler(k, reg), + handler: NewResourceHandler(k, reg, resolvers), + resolvers: resolvers, reconnectManager: NewReconnectManager(config), state: StateDisconnected, ctx: ctx, @@ -333,6 +343,43 @@ func (c *Client) loadTLSConfig() (*tls.Config, error) { }, nil } +// discoveryNode builds the Node this runtime presents on every discovery request. +// +// Beyond identity it advertises what this binary can actually do with the +// resources it is about to receive: the resolution protocol version it implements +// and the sorted list of operation resolvers it has registered. The control plane +// uses that to withhold a route whose resolver this runtime does not have, instead +// of sending it and having every request to it fail to resolve. +// +// The metadata is rebuilt per request rather than cached: it is a handful of string +// copies against a network round trip, and building it from the live registry means +// it cannot go stale relative to what the binary actually serves. +func (c *Client) discoveryNode() *corev3.Node { + node := &corev3.Node{ + Id: constants.XDSNodeID, + Cluster: constants.XDSCluster, + } + + names := []string{} + if c.resolvers != nil { + names = c.resolvers.Names() + } + supported := make([]*structpb.Value, 0, len(names)) + for _, name := range names { + supported = append(supported, structpb.NewStringValue(name)) + } + + node.Metadata = &structpb.Struct{ + Fields: map[string]*structpb.Value{ + constants.NodeMetaResolutionProtocolVersion: structpb.NewNumberValue(float64(resolver.ProtocolVersion)), + constants.NodeMetaSupportedResolvers: structpb.NewListValue( + &structpb.ListValue{Values: supported}, + ), + }, + } + return node +} + // sendDiscoveryRequest sends a DiscoveryRequest to the xDS server func (c *Client) sendDiscoveryRequest(versionInfo, responseNonce string) error { c.mu.RLock() @@ -352,10 +399,7 @@ func (c *Client) sendDiscoveryRequest(versionInfo, responseNonce string) error { TypeUrl: PolicyChainTypeURL, VersionInfo: policyVersion, ResponseNonce: responseNonce, - Node: &corev3.Node{ - Id: constants.XDSNodeID, - Cluster: constants.XDSCluster, - }, + Node: c.discoveryNode(), } slog.DebugContext(c.ctx, "Sending policy chain discovery request", @@ -372,10 +416,7 @@ func (c *Client) sendDiscoveryRequest(versionInfo, responseNonce string) error { TypeUrl: APIKeyStateTypeURL, VersionInfo: apiKeyVersion, ResponseNonce: responseNonce, - Node: &corev3.Node{ - Id: constants.XDSNodeID, - Cluster: constants.XDSCluster, - }, + Node: c.discoveryNode(), } slog.DebugContext(c.ctx, "Sending API key discovery request", @@ -392,10 +433,7 @@ func (c *Client) sendDiscoveryRequest(versionInfo, responseNonce string) error { TypeUrl: LazyResourceTypeURL, VersionInfo: lazyResourceVersion, ResponseNonce: responseNonce, - Node: &corev3.Node{ - Id: constants.XDSNodeID, - Cluster: constants.XDSCluster, - }, + Node: c.discoveryNode(), } slog.DebugContext(c.ctx, "Sending lazy resource discovery request", @@ -412,10 +450,7 @@ func (c *Client) sendDiscoveryRequest(versionInfo, responseNonce string) error { TypeUrl: SubscriptionStateTypeURL, VersionInfo: subscriptionVersion, ResponseNonce: responseNonce, - Node: &corev3.Node{ - Id: constants.XDSNodeID, - Cluster: constants.XDSCluster, - }, + Node: c.discoveryNode(), } slog.DebugContext(c.ctx, "Sending subscription state discovery request", @@ -432,10 +467,7 @@ func (c *Client) sendDiscoveryRequest(versionInfo, responseNonce string) error { TypeUrl: RouteConfigTypeURL, VersionInfo: "", // Initial request ResponseNonce: responseNonce, - Node: &corev3.Node{ - Id: constants.XDSNodeID, - Cluster: constants.XDSCluster, - }, + Node: c.discoveryNode(), } slog.DebugContext(c.ctx, "Sending route config discovery request", @@ -565,10 +597,7 @@ func (c *Client) sendDiscoveryRequestForType(typeURL, versionInfo, responseNonce TypeUrl: typeURL, VersionInfo: versionInfo, ResponseNonce: responseNonce, - Node: &corev3.Node{ - Id: constants.XDSNodeID, - Cluster: constants.XDSCluster, - }, + Node: c.discoveryNode(), } slog.DebugContext(c.ctx, "Sending discovery request for specific type", diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_connection_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_connection_test.go index 890ece187..07da29d02 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_connection_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_connection_test.go @@ -21,6 +21,7 @@ package xdsclient import ( "context" "fmt" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" "io" "path/filepath" "testing" @@ -41,7 +42,7 @@ func TestClient_Dial_InsecureConnection(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 100 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Attempt to dial (will fail due to invalid server, but we test the path) @@ -79,7 +80,7 @@ func TestClient_Dial_TLSConnectionWithValidCerts(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 100 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Attempt to dial (will fail due to invalid server, but TLS config should load) @@ -101,7 +102,7 @@ func TestClient_Dial_TLSConnectionWithInvalidCerts(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 100 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Attempt to dial @@ -120,7 +121,7 @@ func TestClient_Dial_TimeoutApplied(t *testing.T) { config.ServerAddress = "192.0.2.1:99999" // Non-routable IP (TEST-NET-1) config.ConnectTimeout = 200 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) start := time.Now() @@ -141,7 +142,7 @@ func TestClient_Dial_ContextCancellation(t *testing.T) { config.ServerAddress = "192.0.2.1:99999" // Non-routable IP config.ConnectTimeout = 10 * time.Second // Long timeout - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Cancel context before dial @@ -159,7 +160,7 @@ func TestClient_SendDiscoveryRequest_AllTypes(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Create a mock stream @@ -199,7 +200,7 @@ func TestClient_SendDiscoveryRequest_NoStream(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // No stream set @@ -213,7 +214,7 @@ func TestClient_SendDiscoveryRequestForType_PolicyChain(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ @@ -241,7 +242,7 @@ func TestClient_SendDiscoveryRequestForType_APIKey(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ @@ -267,7 +268,7 @@ func TestClient_SendDiscoveryRequestForType_LazyResource(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ @@ -293,7 +294,7 @@ func TestClient_SendDiscoveryRequestForType_NoStream(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // No stream set @@ -307,7 +308,7 @@ func TestClient_ProcessStream_ErrorHandling_Timeout(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ @@ -323,7 +324,7 @@ func TestClient_ProcessStream_ErrorHandling_EOF(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ @@ -339,7 +340,7 @@ func TestClient_ProcessStream_ErrorHandling_NetworkError(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) networkErr := fmt.Errorf("network connection lost") @@ -357,7 +358,7 @@ func TestClient_SendDiscoveryRequest_VersionTracking(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ @@ -392,7 +393,7 @@ func TestClient_GetPolicyChainVersion(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) client.mu.Lock() @@ -441,7 +442,7 @@ func TestClient_ProcessStream_SuccessfulResponse(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Create a valid but minimal response @@ -492,7 +493,7 @@ func TestClient_ProcessStream_UnknownTypeURL(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Response with unknown type URL @@ -540,7 +541,7 @@ func TestClient_SendDiscoveryRequest_SendError(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) mockStream := &mockADSStream{ diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_lifecycle_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_lifecycle_test.go index dfc9ac7f0..4eebe4919 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_lifecycle_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_lifecycle_test.go @@ -19,6 +19,7 @@ package xdsclient import ( + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" "sync" "testing" "time" @@ -32,7 +33,7 @@ func TestClient_Stop_GracefulShutdown(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Verify initial state @@ -70,7 +71,7 @@ func TestClient_Wait_BlocksUntilStopped(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) waitCompleted := make(chan struct{}) @@ -106,7 +107,7 @@ func TestClient_Wait_MultipleGoroutines(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) const numWaiters = 5 @@ -149,7 +150,7 @@ func TestClient_Run_ContextCancellation(t *testing.T) { // Set very short timeout to avoid actual connection attempts config.ConnectTimeout = 1 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) runCompleted := make(chan struct{}) @@ -184,7 +185,7 @@ func TestClient_Run_ReconnectLoop(t *testing.T) { config.ConnectTimeout = 100 * time.Millisecond config.InitialReconnectDelay = 50 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) runCompleted := make(chan struct{}) @@ -261,7 +262,7 @@ func TestClient_ConnectAndRun_ConnectionFailure(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 100 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // connectAndRun should return an error @@ -278,7 +279,7 @@ func TestClient_Start_StartsBackgroundLoop(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 50 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Start should not block @@ -307,7 +308,7 @@ func TestClient_Stop_WithActiveConnection(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Simulate having a connection (Note: we're not actually connecting to avoid test infrastructure) @@ -330,7 +331,7 @@ func TestClient_Run_ImmediateStop(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 50 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) runCompleted := make(chan struct{}) @@ -361,7 +362,7 @@ func TestClient_Lifecycle_CompleteFlow(t *testing.T) { config.ConnectTimeout = 50 * time.Millisecond // 1. Create client - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) assert.Equal(t, StateDisconnected, client.GetState()) @@ -400,7 +401,7 @@ func TestClient_SetState_ThreadSafety(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) const numGoroutines = 10 @@ -457,7 +458,7 @@ func TestClient_ConnectAndRun_ContextCancelledDuringDial(t *testing.T) { config.ServerAddress = "invalid-server:99999" config.ConnectTimeout = 5 * time.Second // Long timeout - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Cancel context before calling connectAndRun @@ -477,7 +478,7 @@ func TestClient_Run_ReconnectManager_BackoffBehavior(t *testing.T) { config.InitialReconnectDelay = 20 * time.Millisecond config.MaxReconnectDelay = 100 * time.Millisecond - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Verify ReconnectManager is initialized diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go index 710ca067f..b3cdee498 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/client_test.go @@ -35,6 +35,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" ) // Helper to create a minimal valid config for testing @@ -100,7 +101,7 @@ func TestNewClient_InvalidConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client, err := NewClient(tt.config, k, reg) + client, err := NewClient(tt.config, k, reg, resolver.DefaultRegistry()) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "invalid config") @@ -113,7 +114,7 @@ func TestNewClient_ValidConfig(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) require.NotNil(t, client) @@ -129,7 +130,7 @@ func TestIsHealthy_BeforeFirstConfig(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Before any config is received, policyChainVersion is empty @@ -141,7 +142,7 @@ func TestIsHealthy_AfterFirstConfig(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Simulate receiving a config by setting the version @@ -157,7 +158,7 @@ func TestGetState(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Initial state should be Disconnected @@ -170,7 +171,7 @@ func TestSetState(t *testing.T) { k, reg := createTestKernelAndRegistry(t) config := createValidTestConfig() - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Test state transitions @@ -316,7 +317,7 @@ func TestLoadTLSConfig_ValidCerts(t *testing.T) { config.TLSKeyPath = keyPath config.TLSCAPath = caPath - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) // Test loadTLSConfig @@ -350,7 +351,7 @@ func TestLoadTLSConfig_InvalidCertPath(t *testing.T) { config.TLSKeyPath = keyPath config.TLSCAPath = caPath - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) tlsConfig, err := client.loadTLSConfig() @@ -380,7 +381,7 @@ func TestLoadTLSConfig_InvalidKeyPath(t *testing.T) { config.TLSKeyPath = "/nonexistent/key.pem" config.TLSCAPath = caPath - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) tlsConfig, err := client.loadTLSConfig() @@ -410,7 +411,7 @@ func TestLoadTLSConfig_InvalidCAPath(t *testing.T) { config.TLSKeyPath = keyPath config.TLSCAPath = "/nonexistent/ca.pem" - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) tlsConfig, err := client.loadTLSConfig() @@ -445,7 +446,7 @@ func TestLoadTLSConfig_InvalidCAFormat(t *testing.T) { config.TLSKeyPath = keyPath config.TLSCAPath = caPath - client, err := NewClient(config, k, reg) + client, err := NewClient(config, k, reg, resolver.DefaultRegistry()) require.NoError(t, err) tlsConfig, err := client.loadTLSConfig() diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go index 478e183b2..7ee2b7eee 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler.go @@ -21,8 +21,11 @@ package xdsclient import ( "context" "encoding/json" + "errors" "fmt" "log/slog" + "math" + "strconv" "strings" "google.golang.org/protobuf/encoding/protojson" @@ -34,6 +37,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) @@ -64,6 +68,7 @@ type appliedRoute struct { type ResourceHandler struct { kernel *kernel.Kernel registry *registry.PolicyRegistry + resolvers resolver.ResolverRegistry configLoader *kernel.ConfigLoader apiKeyHandler *APIKeyOperationHandler lazyResourceHandler *LazyResourceHandler @@ -78,14 +83,18 @@ type ResourceHandler struct { lastApplied map[string]appliedRoute } -// NewResourceHandler creates a new ResourceHandler -func NewResourceHandler(k *kernel.Kernel, reg *registry.PolicyRegistry) *ResourceHandler { +// NewResourceHandler creates a new ResourceHandler. +// resolvers is the frozen operation-resolver registry; a nil value is treated as +// an identity-only registry, so a route naming any other resolver is skipped at +// ingest rather than resolving by identity behind the operator's back. +func NewResourceHandler(k *kernel.Kernel, reg *registry.PolicyRegistry, resolvers resolver.ResolverRegistry) *ResourceHandler { apiKeyStore := apikey.GetAPIkeyStoreInstance() lazyResourceStore := policy.GetLazyResourceStoreInstance() subStore := policyenginev1.GetSubscriptionStoreInstance() return &ResourceHandler{ kernel: k, registry: reg, + resolvers: resolvers, configLoader: kernel.NewConfigLoader(k, reg), apiKeyHandler: NewAPIKeyOperationHandler(apiKeyStore, slog.Default()), lazyResourceHandler: NewLazyResourceHandler(lazyResourceStore, slog.Default()), @@ -368,6 +377,14 @@ func (h *ResourceHandler) HandleRouteConfigUpdate(ctx context.Context, resources } } + // Resolution config: how this route's policy chain key is derived. + // A route the resolver registry cannot serve is skipped entirely rather + // than silently falling back to identity resolution, which would select + // the route-level chain for every logical operation and look like it works. + if !h.applyRouteResolution(ctx, routeKey, rc, data) { + continue + } + rc.Metadata.DefaultUpstreamCluster = getStringFromMap(data, "default_upstream_cluster") rc.Metadata.UpstreamBasePath = getStringFromMap(data, "upstream_base_path") @@ -399,6 +416,68 @@ func (h *ResourceHandler) HandleRouteConfigUpdate(ctx context.Context, resources return nil } +// applyRouteResolution parses a route's resolution fields (resolver_name, +// canonical_chain_key, resolver_config, max_request_body_bytes) onto rc and runs +// the resolver's Prepare hook. +// +// It reports whether the route is usable. A false return skips this one route with +// a logged reason and a metric — it never fails the whole update, because under +// State-of-the-World a NACK keeps the previous version of *every* RouteConfig, so +// one bad deployment would freeze route updates for every API on the gateway. This +// matches the existing per-entry convention in HandleRouteConfigUpdate, where +// structural failures (proto/protojson unmarshal) return an error but per-entry +// semantic problems (unknown type URL, empty route_key) log and continue. +func (h *ResourceHandler) applyRouteResolution( + ctx context.Context, + routeKey string, + rc *kernel.RouteConfig, + data map[string]interface{}, +) bool { + // canonical_chain_key is emitted on every directly-resolved route by a current + // controller. An older controller omits it, in which case the route key is the + // chain key — which is exactly what the pre-resolution policy engine assumed. + // kernel.PrepareRoute applies that fallback, once, below. + rc.CanonicalChainKey = getStringFromMap(data, "canonical_chain_key") + rc.ResolverName = getStringFromMap(data, "resolver_name") + rc.MaxRequestBodyBytes = getInt64FromMap(data, "max_request_body_bytes") + + // There is no operation map to parse or validate: a resolver-bearing route's chain + // key is composed from the identified operation at request time, so completeness + // is a question about the *chains*, which only the controller can answer at deploy + // time. An unrecognised operation is a per-request outcome, not a bad route. + + if raw, present := data["resolver_config"]; present && raw != nil { + encoded, err := json.Marshal(raw) + if err != nil { + slog.WarnContext(ctx, "Skipping route: resolver_config could not be re-encoded", + "route", routeKey, "resolver", rc.ResolverName, "error", err) + metrics.RouteResolutionIngestFailuresTotal.WithLabelValues("invalid_resolver_config").Inc() + return false + } + rc.ResolverConfig = encoded + } + + // Prepare runs once per route, here, so a resolver that must validate + // configuration, compile a schema or build an index never does it per request. It + // is mandatory: even an identity route is prepared, which is what removes the + // special case from the request path. + if err := kernel.PrepareRoute(h.resolvers, routeKey, rc); err != nil { + var re *resolver.ResolutionError + if errors.As(err, &re) && re.Kind == resolver.FailureUnknownResolver { + slog.WarnContext(ctx, "Skipping route: unknown operation resolver", + "route", routeKey, "resolver", rc.ResolverName) + metrics.RouteResolutionIngestFailuresTotal.WithLabelValues("unknown_resolver").Inc() + return false + } + slog.WarnContext(ctx, "Skipping route: resolver preparation failed", + "route", routeKey, "resolver", rc.ResolverName, "error", err) + metrics.RouteResolutionIngestFailuresTotal.WithLabelValues("prepare_failed").Inc() + return false + } + + return true +} + // getStringFromMap safely extracts a string value from a map. func getStringFromMap(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { @@ -409,6 +488,43 @@ func getStringFromMap(m map[string]interface{}, key string) string { return "" } +// getInt64FromMap safely extracts an integer value from a map. protojson renders +// every Struct number as a float64, and a JSON producer may also emit it as a +// string, so both are accepted. Anything that is not a positive whole number +// representable as an int64 reads as 0, which callers treat as "not configured" and +// resolve to their own default. +func getInt64FromMap(m map[string]interface{}, key string) int64 { + v, ok := m[key] + if !ok { + return 0 + } + switch n := v.(type) { + case float64: + // The range check has to happen before the conversion, not after. Go leaves + // float-to-int conversion undefined when the value does not fit, and on this + // platform it *saturates*: int64(float64(1<<63)) yields math.MaxInt64. A + // nonsense value would therefore become the largest possible ceiling rather + // than falling back to the default — the wrong direction for a limit that + // bounds unauthenticated work. + // + // A fractional value is rejected rather than truncated: a byte count of 4096.5 + // is malformed config, and silently serving 4096 hides the producer's bug. + // NaN falls out of the Trunc comparison, since NaN != NaN. + if n < 1 || n >= math.Ldexp(1, 63) || n != math.Trunc(n) { + return 0 + } + return int64(n) + case string: + parsed, err := strconv.ParseInt(n, 10, 64) + if err != nil || parsed <= 0 { + return 0 + } + return parsed + default: + return 0 + } +} + // convertStoredConfigToPolicyChains extracts PolicyChain configurations from StoredPolicyConfig // With SDK types, the routes are already in the correct format func (h *ResourceHandler) convertStoredConfigToPolicyChains(stored *StoredPolicyConfig) []*policyenginev1.PolicyChain { diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler_test.go index 5f1ee7dfc..5eac151fe 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/handler_test.go @@ -30,6 +30,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) @@ -43,7 +44,7 @@ func TestNewResourceHandler(t *testing.T) { Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) require.NotNil(t, handler) assert.NotNil(t, handler.kernel) @@ -62,7 +63,7 @@ func TestConvertStoredConfigToPolicyChains_Empty(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) stored := &StoredPolicyConfig{ Configuration: policyenginev1.Configuration{ @@ -80,7 +81,7 @@ func TestConvertStoredConfigToPolicyChains_MultipleRoutes(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) stored := &StoredPolicyConfig{ ID: "test-api", @@ -108,7 +109,7 @@ func TestValidatePolicyChainConfig_EmptyRouteKey(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "", @@ -125,7 +126,7 @@ func TestValidatePolicyChainConfig_PolicyMissingName(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", @@ -145,7 +146,7 @@ func TestValidatePolicyChainConfig_PolicyMissingVersion(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", @@ -165,7 +166,7 @@ func TestValidatePolicyChainConfig_PolicyNotInRegistry(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", @@ -185,7 +186,7 @@ func TestValidatePolicyChainConfig_NoPolicies(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", @@ -206,7 +207,7 @@ func TestGetAllRouteKeys(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) // Currently returns empty slice as xDS State of the World sends all routes result := handler.getAllRouteKeys() @@ -224,7 +225,7 @@ func TestHandlePolicyChainUpdate_EmptyResources(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() err := handler.HandlePolicyChainUpdate(ctx, []*anypb.Any{}, "v1") @@ -237,7 +238,7 @@ func TestHandlePolicyChainUpdate_WrongTypeURL(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() resources := []*anypb.Any{ @@ -255,7 +256,7 @@ func TestHandlePolicyChainUpdate_InvalidInnerAny(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() resources := []*anypb.Any{ @@ -273,7 +274,7 @@ func TestHandlePolicyChainUpdate_InvalidStructInInnerAny(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) // Create an inner Any with invalid Struct data innerAny := &anypb.Any{ @@ -298,7 +299,7 @@ func TestHandlePolicyChainUpdate_ValidEmptyConfig(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) // Create a valid empty stored config storedConfig := map[string]interface{}{ @@ -342,7 +343,7 @@ func TestHandlePolicyChainUpdate_RouteWithInvalidPolicy(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) // Create config with a route that has a nonexistent policy storedConfig := map[string]interface{}{ @@ -398,7 +399,7 @@ func TestHandlePolicyChainUpdate_RouteWithEmptyKey(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) // Create config with a route that has empty key storedConfig := map[string]interface{}{ @@ -451,7 +452,7 @@ func TestBuildPolicyChain_EmptyConfig(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", @@ -480,7 +481,7 @@ func TestBuildPolicyChain_UnknownPolicy(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", @@ -511,7 +512,7 @@ func TestBuildPolicyChain_MetadataPropagation(t *testing.T) { reg := ®istry.PolicyRegistry{ Policies: make(map[string]*registry.PolicyEntry), } - handler := NewResourceHandler(k, reg) + handler := NewResourceHandler(k, reg, resolver.DefaultRegistry()) config := &policyenginev1.PolicyChain{ RouteKey: "test-route", diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/reconcile_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/reconcile_test.go index 0e925de77..2aebde375 100644 --- a/gateway/gateway-runtime/policy-engine/internal/xdsclient/reconcile_test.go +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/reconcile_test.go @@ -36,6 +36,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" policy "github.com/wso2/api-platform/sdk/core/policy/v1alpha2" policyenginev1 "github.com/wso2/api-platform/sdk/core/policyengine" ) @@ -141,7 +142,7 @@ func TestHandlePolicyChainUpdate_ReusesUnchangedRoutesOnUnrelatedRedeploy(t *tes metrics.Init() reg, counts := regWithCounters(t, "polA:v1", "polB:v1") k := kernel.NewKernel() - h := NewResourceHandler(k, reg) + h := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() rA := route("rA", pol("polA", "v1", map[string]interface{}{"x": "1"})) @@ -174,7 +175,7 @@ func TestHandlePolicyChainUpdate_ReuseAcrossVolatileMetadataBump(t *testing.T) { metrics.Init() reg, counts := regWithCounters(t, "polA:v1") k := kernel.NewKernel() - h := NewResourceHandler(k, reg) + h := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() rA := route("rA", pol("polA", "v1", map[string]interface{}{"x": "1"})) @@ -193,7 +194,7 @@ func TestHandlePolicyChainUpdate_ReorderRebuilds(t *testing.T) { metrics.Init() reg, counts := regWithCounters(t, "polA:v1", "polB:v1") k := kernel.NewKernel() - h := NewResourceHandler(k, reg) + h := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() pA := pol("polA", "v1", map[string]interface{}{"x": "1"}) @@ -216,7 +217,7 @@ func TestHandlePolicyChainUpdate_RemovesAbsentRoutes(t *testing.T) { metrics.Init() reg, _ := regWithCounters(t, "polA:v1") k := kernel.NewKernel() - h := NewResourceHandler(k, reg) + h := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() rA := route("rA", pol("polA", "v1", nil)) @@ -396,7 +397,7 @@ func TestHandlePolicyChainUpdate_FailedRebuildNotReportedRemoved(t *testing.T) { } k := kernel.NewKernel() - h := NewResourceHandler(k, reg) + h := NewResourceHandler(k, reg, resolver.DefaultRegistry()) ctx := context.Background() // Snapshot 1: rB applied successfully. diff --git a/gateway/gateway-runtime/policy-engine/internal/xdsclient/route_resolution_test.go b/gateway/gateway-runtime/policy-engine/internal/xdsclient/route_resolution_test.go new file mode 100644 index 000000000..7985fe9c6 --- /dev/null +++ b/gateway/gateway-runtime/policy-engine/internal/xdsclient/route_resolution_test.go @@ -0,0 +1,484 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package xdsclient + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/kernel" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/resolver" +) + +// ─── Fakes ─────────────────────────────────────────────────────────────────── + +// stubResolver is a factory that records what ingest handed it. err, when set, is what +// Prepare fails with, so a preparation failure can be driven from a test. +type stubResolver struct { + name string + reqs resolver.RequestRequirements + err error + + calls int + seenConfigs []string + seenRoutes []resolver.ResolverRouteConfig +} + +func (s *stubResolver) Name() string { return s.name } + +func (s *stubResolver) Prepare(cfg resolver.ResolverRouteConfig) (resolver.PreparedResolver, error) { + s.calls++ + s.seenConfigs = append(s.seenConfigs, string(cfg.ResolverConfig)) + s.seenRoutes = append(s.seenRoutes, cfg) + if s.err != nil { + return nil, s.err + } + return &stubPrepared{reqs: s.reqs}, nil +} + +type stubPrepared struct { + reqs resolver.RequestRequirements +} + +func (s *stubPrepared) Requirements() resolver.RequestRequirements { return s.reqs } + +func (s *stubPrepared) Resolve(context.Context, resolver.RequestView) (resolver.Resolution, error) { + return resolver.Resolution{}, nil +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func newRouteHandler(t *testing.T, resolvers resolver.ResolverRegistry) (*ResourceHandler, *kernel.Kernel) { + t.Helper() + k := kernel.NewKernel() + reg := ®istry.PolicyRegistry{Policies: make(map[string]*registry.PolicyEntry)} + return NewResourceHandler(k, reg, resolvers), k +} + +func registryWithResolvers(t *testing.T, resolvers ...resolver.Resolver) resolver.ResolverRegistry { + t.Helper() + reg := resolver.NewRegistry() + // route-key is always present, exactly as it is in production: a route with no + // resolver_name normalises to it. + require.NoError(t, reg.Register(&resolver.RouteKeyResolver{})) + for _, r := range resolvers { + require.NoError(t, reg.Register(r)) + } + reg.Freeze() + return reg +} + +// routeConfigResource wraps a route config map the way the control plane does: +// a Struct inside an Any inside the outer resource Any. +func routeConfigResource(t *testing.T, data map[string]interface{}) *anypb.Any { + t.Helper() + s, err := structpb.NewStruct(data) + require.NoError(t, err) + structBytes, err := proto.Marshal(s) + require.NoError(t, err) + innerAny := &anypb.Any{TypeUrl: "type.googleapis.com/google.protobuf.Struct", Value: structBytes} + innerBytes, err := proto.Marshal(innerAny) + require.NoError(t, err) + return &anypb.Any{TypeUrl: RouteConfigTypeURL, Value: innerBytes} +} + +// ─── Identity routes ───────────────────────────────────────────────────────── + +func TestRouteConfigUpdate_IdentityRouteParsesCanonicalChainKey(t *testing.T) { + h, k := newRouteHandler(t, resolver.DefaultRegistry()) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "GET|/pets|example.com", + "resolver_name": "route-key", + "canonical_chain_key": "GET|/pets|example.com", + "metadata": map[string]interface{}{"display_name": "pets", "vhost": "example.com"}, + }), + }, "v1") + require.NoError(t, err) + + rc := k.GetRouteConfig("GET|/pets|example.com") + require.NotNil(t, rc) + assert.Equal(t, "GET|/pets|example.com", rc.RouteKey) + assert.Equal(t, "GET|/pets|example.com", rc.CanonicalChainKey) + assert.Equal(t, "route-key", rc.ResolverName) + assert.True(t, rc.IsIdentity()) +} + +// An older controller omits canonical_chain_key entirely. For an identity route the +// route key is the chain key, which is exactly what the pre-resolution engine assumed. +func TestRouteConfigUpdate_AbsentCanonicalChainKeyFallsBackToRouteKey(t *testing.T) { + h, k := newRouteHandler(t, resolver.DefaultRegistry()) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "GET|/pets|example.com", + "resolver_name": "route-key", + }), + }, "v1") + require.NoError(t, err) + + rc := k.GetRouteConfig("GET|/pets|example.com") + require.NotNil(t, rc) + assert.Equal(t, "GET|/pets|example.com", rc.CanonicalChainKey) +} + +// An empty resolver_name is identity too — that is what every existing kind's routes +// looked like before this field was populated per route. +func TestRouteConfigUpdate_EmptyResolverNameIsIdentity(t *testing.T) { + h, k := newRouteHandler(t, resolver.DefaultRegistry()) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{"route_key": "GET|/pets|example.com"}), + }, "v1") + require.NoError(t, err) + + rc := k.GetRouteConfig("GET|/pets|example.com") + require.NotNil(t, rc) + assert.True(t, rc.IsIdentity()) +} + +// ─── Non-identity routes ───────────────────────────────────────────────────── + +func TestRouteConfigUpdate_ParsesResolverConfigAndBufferLimit(t *testing.T) { + prep := &stubResolver{ + name: "fake-jsonrpc", + reqs: resolver.RequestRequirements{Body: resolver.BodyBuffered}, + } + h, k := newRouteHandler(t, registryWithResolvers(t, prep)) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "fake-jsonrpc", + "max_request_body_bytes": 4096, + "resolver_config": map[string]interface{}{"protocolVersion": "1.0"}, + "metadata": map[string]interface{}{ + "uuid": "agent-1", "vhost": "example.com", "path": "/rpc", + }, + }), + }, "v1") + require.NoError(t, err) + + rc := k.GetRouteConfig("POST|/rpc|example.com") + require.NotNil(t, rc) + assert.False(t, rc.IsIdentity()) + assert.Equal(t, int64(4096), rc.MaxRequestBodyBytes) + assert.JSONEq(t, `{"protocolVersion":"1.0"}`, string(rc.ResolverConfig)) + + // Prepare runs once per route at ingest, so no request pays for it, and the route + // stores the requirements it declared. + assert.Equal(t, 1, prep.calls) + require.NotNil(t, rc.Prepared) + assert.True(t, rc.Prepared.Requirements.BuffersBody()) + assert.False(t, rc.Prepared.IsStatic(), "this route resolves per request") + + // The whole static route reaches Prepare, so a resolver captures its partition and + // configuration once instead of receiving them per request. + seen := prep.seenRoutes[0] + assert.Equal(t, "agent-1", seen.APIID) + assert.Equal(t, "example.com", seen.Vhost) + assert.Equal(t, "/rpc", seen.Path) + assert.JSONEq(t, `{"protocolVersion":"1.0"}`, string(seen.ResolverConfig)) +} + +// GO-AUTH-006: the method is upper-cased before Prepare sees it, so no Prepare +// implementation can miss on case. Its only source is the route name, which the wire +// carries as METHOD|fullPath|vhost. +func TestRouteConfigUpdate_MethodReachesPrepareUpperCased(t *testing.T) { + prep := &stubResolver{name: "fake-jsonrpc"} + h, _ := newRouteHandler(t, registryWithResolvers(t, prep)) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "post|/rpc|example.com", + "resolver_name": "fake-jsonrpc", + }), + }, "v1")) + + require.Len(t, prep.seenRoutes, 1) + assert.Equal(t, "POST", prep.seenRoutes[0].Method) +} + +// Every route is prepared, including an identity one — that is what removes the special +// case from the request path. +func TestRouteConfigUpdate_IdentityRouteIsPreparedStatically(t *testing.T) { + h, k := newRouteHandler(t, resolver.DefaultRegistry()) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{"route_key": "GET|/pets|example.com"}), + }, "v1")) + + rc := k.GetRouteConfig("GET|/pets|example.com") + require.NotNil(t, rc) + require.NotNil(t, rc.Prepared) + assert.True(t, rc.Prepared.IsStatic(), "an identity route resolves entirely at ingest") + assert.False(t, rc.Prepared.Requirements.BuffersBody()) + assert.Equal(t, resolver.RouteKeyResolverName, rc.Prepared.ResolverName, + "an empty resolver_name is normalised to the identity resolver") +} + +// A route naming a resolver this binary does not have is dropped. Keeping it would +// mean every request to it fails at runtime; resolving it by identity would apply the +// route-level chain to every operation the route multiplexes. +func TestRouteConfigUpdate_UnknownResolverSkipsTheRoute(t *testing.T) { + h, k := newRouteHandler(t, resolver.DefaultRegistry()) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "a2a-jsonrpc", + }), + }, "v1") + + require.NoError(t, err, "a per-entry problem must never NACK the snapshot") + assert.Nil(t, k.GetRouteConfig("POST|/rpc|example.com")) +} + +// Under composed keys a resolver-bearing route carries no operation map, so there is +// nothing to validate at ingest and the route must be admitted on its own. Whether the +// chains it will compose keys for actually exist is a deploy-time question the +// controller answers (it is the only side that can enumerate them); at request time a +// key with no chain is a per-request failure, not a reason to drop the route. +func TestRouteConfigUpdate_ResolverBearingRouteNeedsNoOperationMap(t *testing.T) { + h, k := newRouteHandler(t, registryWithResolvers(t, &stubResolver{name: "fake-jsonrpc"})) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), + []*anypb.Any{routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "fake-jsonrpc", + })}, "v1")) + + rc := k.GetRouteConfig("POST|/rpc|example.com") + require.NotNil(t, rc, "a resolver-bearing route is complete without an operation map") + assert.False(t, rc.IsIdentity()) +} + +// An operation_map from an older controller is ignored rather than rejected: the field +// is gone from the contract, and a route that is otherwise valid must not be dropped +// over a value nothing reads. +func TestRouteConfigUpdate_StaleOperationMapIsIgnored(t *testing.T) { + h, k := newRouteHandler(t, registryWithResolvers(t, &stubResolver{name: "fake-jsonrpc"})) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), + []*anypb.Any{routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "fake-jsonrpc", + "operation_map": map[string]interface{}{"SendMessage": "chain-send"}, + })}, "v1")) + + assert.NotNil(t, k.GetRouteConfig("POST|/rpc|example.com")) +} + +// A Prepare error must drop only its own route. Under State-of-the-World a NACK keeps +// the previous version of every RouteConfig, so failing the whole update would freeze +// route updates for every API on the gateway. +func TestRouteConfigUpdate_PrepareErrorSkipsOnlyThatRoute(t *testing.T) { + failing := &stubResolver{name: "failing", err: errors.New("schema does not compile")} + ok := &stubResolver{name: "working"} + h, k := newRouteHandler(t, registryWithResolvers(t, failing, ok)) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/bad|example.com", + "resolver_name": "failing", + }), + routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/good|example.com", + "resolver_name": "working", + }), + routeConfigResource(t, map[string]interface{}{ + "route_key": "GET|/pets|example.com", + }), + }, "v1") + + require.NoError(t, err, "a failing Prepare must not NACK the snapshot") + assert.Nil(t, k.GetRouteConfig("POST|/bad|example.com"), "the failing route is dropped") + assert.NotNil(t, k.GetRouteConfig("POST|/good|example.com"), "a sibling route in the same update still applies") + assert.NotNil(t, k.GetRouteConfig("GET|/pets|example.com"), "an identity route is unaffected") +} + +// A nil resolver registry must be treated as identity-only rather than panicking or +// admitting a route it cannot serve. +func TestRouteConfigUpdate_NilResolverRegistryIsIdentityOnly(t *testing.T) { + h, k := newRouteHandler(t, nil) + + err := h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{"route_key": "GET|/pets|example.com"}), + routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "a2a-jsonrpc", + }), + }, "v1") + + require.NoError(t, err) + assert.NotNil(t, k.GetRouteConfig("GET|/pets|example.com")) + assert.Nil(t, k.GetRouteConfig("POST|/rpc|example.com")) +} + +// A resolver that ignores its configuration still gets it: whether to read +// resolver_config is the resolver's business, and the route is admitted either way. +func TestRouteConfigUpdate_ResolverConfigReachesPrepareEvenIfUnused(t *testing.T) { + stub := &stubResolver{name: "ignores-config"} + h, k := newRouteHandler(t, registryWithResolvers(t, stub)) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), []*anypb.Any{ + routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "ignores-config", + "resolver_config": map[string]interface{}{"ignored": true}, + }), + }, "v1")) + + rc := k.GetRouteConfig("POST|/rpc|example.com") + require.NotNil(t, rc) + require.NotNil(t, rc.Prepared) + assert.JSONEq(t, `{"ignored":true}`, stub.seenConfigs[0]) +} + +// ─── Numeric field parsing ─────────────────────────────────────────────────── + +// protojson renders every Struct number as a float64, and a producer may also send it +// as a string; a nonsensical value must read as "not configured" so the route falls +// back to the low default rather than being left unbounded. +func TestGetInt64FromMap(t *testing.T) { + tests := []struct { + name string + in interface{} + want int64 + }{ + {"float", float64(4096), 4096}, + {"string", "8192", 8192}, + {"zero", float64(0), 0}, + {"negative", float64(-1), 0}, + {"negative string", "-1", 0}, + {"unparseable string", "many", 0}, + {"wrong type", true, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, getInt64FromMap(map[string]interface{}{"k": tt.in}, "k")) + }) + } + assert.Equal(t, int64(0), getInt64FromMap(map[string]interface{}{}, "missing")) +} + +// ─── Capability advertisement ──────────────────────────────────────────────── + +// The control plane needs to know what this runtime can resolve before it sends a +// route that needs it, so every discovery request carries the advertisement. +func TestDiscoveryNode_AdvertisesResolverCapabilities(t *testing.T) { + c := &Client{resolvers: registryWithResolvers(t, + &stubResolver{name: "zeta"}, &stubResolver{name: "alpha"})} + + node := c.discoveryNode() + require.NotNil(t, node.Metadata) + + version := node.Metadata.Fields["resolution_protocol_version"].GetNumberValue() + assert.Equal(t, float64(resolver.ProtocolVersion), version) + + list := node.Metadata.Fields["supported_resolvers"].GetListValue() + require.NotNil(t, list) + var names []string + for _, v := range list.Values { + names = append(names, v.GetStringValue()) + } + assert.Equal(t, []string{"alpha", resolver.RouteKeyResolverName, "zeta"}, names, + "the advertised list is sorted so the control plane can compare it cheaply") +} + +// A client with no registry must still advertise, with an empty list — the control +// plane then withholds every resolver-bearing route rather than guessing. +func TestDiscoveryNode_NoRegistryAdvertisesEmptyList(t *testing.T) { + c := &Client{} + node := c.discoveryNode() + + require.NotNil(t, node.Metadata) + assert.Empty(t, node.Metadata.Fields["supported_resolvers"].GetListValue().Values) +} + +// A malformed byte ceiling must read as "not configured" so the route falls back to the +// engine's own low default, rather than being truncated to a nearby value or — worse — +// saturating. int64(float64(1<<63)) yields math.MaxInt64 on this platform, which would turn +// a nonsense value into an effectively unbounded ceiling on a limit whose whole job is +// bounding unauthenticated work. +func TestRouteConfigUpdate_MalformedBodyLimitReadsAsUnconfigured(t *testing.T) { + for name, limit := range map[string]interface{}{ + "fractional below one": 0.5, + "fractional": 4096.5, + "above int64 range": float64(1 << 63), + "negative": -4096.0, + "zero": 0.0, + "not a number": "not-a-number", + } { + t.Run(name, func(t *testing.T) { + h, k := newRouteHandler(t, registryWithResolvers(t, &stubResolver{name: "fake-jsonrpc"})) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), + []*anypb.Any{routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "fake-jsonrpc", + "max_request_body_bytes": limit, + })}, "v1")) + + rc := k.GetRouteConfig("POST|/rpc|example.com") + require.NotNil(t, rc, "a malformed limit must not drop the route") + assert.Zero(t, rc.MaxRequestBodyBytes, "malformed limits read as not configured") + assert.Equal(t, kernel.DefaultMaxResolverRequestBodyBytes, rc.EffectiveMaxRequestBodyBytes(), + "and the route falls back to the engine default") + }) + } +} + +// The whole-number values a producer legitimately sends still arrive intact, including one +// emitted as a string. +func TestRouteConfigUpdate_ValidBodyLimitsAreAccepted(t *testing.T) { + for name, tc := range map[string]struct { + limit interface{} + want int64 + }{ + "float": {limit: 4096.0, want: 4096}, + "string": {limit: "4096", want: 4096}, + "one byte": {limit: 1.0, want: 1}, + "largest int64": {limit: "9223372036854775807", want: 9223372036854775807}, + } { + t.Run(name, func(t *testing.T) { + h, k := newRouteHandler(t, registryWithResolvers(t, &stubResolver{name: "fake-jsonrpc"})) + + require.NoError(t, h.HandleRouteConfigUpdate(context.Background(), + []*anypb.Any{routeConfigResource(t, map[string]interface{}{ + "route_key": "POST|/rpc|example.com", + "resolver_name": "fake-jsonrpc", + "max_request_body_bytes": tc.limit, + })}, "v1")) + + rc := k.GetRouteConfig("POST|/rpc|example.com") + require.NotNil(t, rc) + assert.Equal(t, tc.want, rc.MaxRequestBodyBytes) + }) + } +}