diff --git a/event-gateway/gateway-controller/cmd/controller/main.go b/event-gateway/gateway-controller/cmd/controller/main.go index 58ac574904..49003ed897 100644 --- a/event-gateway/gateway-controller/cmd/controller/main.go +++ b/event-gateway/gateway-controller/cmd/controller/main.go @@ -365,8 +365,11 @@ func main() { policyDefinitions[key] = def } + // Built early so the startup rehydration below can use it too. + policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) + if err := hydrateStoredConfigsFromDatabaseOnStartup( - configStore, db, &cfg.Router, policyDefinitions, log, + configStore, db, &cfg.Router, policyDefinitions, policyVersionResolver, log, cfg.Controller.Server.SkipInvalidDeploymentsOnStartup, ); err != nil { log.Error("Failed to hydrate stored configurations required for startup", slog.Any("error", err)) @@ -427,7 +430,6 @@ func main() { policyManager := policyxds.NewPolicyManager(policySnapshotManager, log) policyManager.SetRuntimeStore(runtimeStore) - policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) @@ -482,7 +484,7 @@ func main() { validator.SetPolicyValidator(policyValidator) apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) - mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) + mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService, policyVersionResolver) llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, apiSvc, &cfg.Router, policyVersionResolver, policyValidator) cpClient := controlplane.NewClient( @@ -534,7 +536,7 @@ func main() { evtListener := coreeventlistener.NewEventListener( eventHubInstance, configStore, db, snapshotManager, subscriptionSnapshotManager, apiKeyXDSManager, lazyResourceXDSManager, policyManager, &cfg.Router, log, cfg, - policyDefinitions, secretsService, + policyDefinitions, secretsService, policyVersionResolver, ) if webhookSecretService != nil { evtListener.SetWebhookSecretHandler(eventlistener.NewWebhookSecretHandler(db, encryptionProviderManager, webhookSecretStore, webhookSecretSnapshotManager, log)) diff --git a/event-gateway/gateway-controller/cmd/controller/runtime_bootstrap.go b/event-gateway/gateway-controller/cmd/controller/runtime_bootstrap.go index 39afc777ca..177fcf9d2d 100644 --- a/event-gateway/gateway-controller/cmd/controller/runtime_bootstrap.go +++ b/event-gateway/gateway-controller/cmd/controller/runtime_bootstrap.go @@ -41,6 +41,7 @@ func hydrateStoredConfigsFromDatabaseOnStartup( db storage.Storage, routerConfig *config.RouterConfig, policyDefinitions map[string]models.PolicyDefinition, + policyVersionResolver utils.PolicyVersionResolver, log *slog.Logger, skipInvalidDeployments bool, ) error { @@ -53,7 +54,9 @@ func hydrateStoredConfigsFromDatabaseOnStartup( "stored MCP proxy configuration", log, skipInvalidDeployments, - utils.HydrateStoredMCPConfig, + func(cfg *models.StoredConfig) error { + return utils.HydrateStoredMCPConfig(cfg, policyVersionResolver) + }, ); err != nil { return err } diff --git a/gateway/build-manifest.yaml b/gateway/build-manifest.yaml index 1524b5423b..239c716427 100644 --- a/gateway/build-manifest.yaml +++ b/gateway/build-manifest.yaml @@ -90,6 +90,9 @@ policies: - name: nvidia-nemoguard-content-safety version: v0.9.0 pipPackage: git+https://github.com/wso2/gateway-controllers.git@policies/nvidia-nemoguard-content-safety/v0.9.0#subdirectory=policies/nvidia-nemoguard-content-safety + - name: oauth2-generator + version: v0.9.0 + gomodule: github.com/wso2/gateway-controllers/policies/oauth2-generator@v0 - name: opaque-token-auth version: v1.0.1 gomodule: github.com/wso2/gateway-controllers/policies/opaque-token-auth@v1 diff --git a/gateway/build.yaml b/gateway/build.yaml index 84e87bdf80..b79a814b52 100644 --- a/gateway/build.yaml +++ b/gateway/build.yaml @@ -62,6 +62,8 @@ policies: gomodule: github.com/wso2/gateway-controllers/policies/model-weighted-round-robin@v1 - name: nvidia-nemoguard-content-safety pipPackage: github.com/wso2/gateway-controllers/policies/nvidia-nemoguard-content-safety@v0 + - name: oauth2-generator + gomodule: github.com/wso2/gateway-controllers/policies/oauth2-generator@v0 - name: opaque-token-auth gomodule: github.com/wso2/gateway-controllers/policies/opaque-token-auth@v1 - name: openai-to-anthropic-transformer diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 6ea472d7e5..4cb2178fd7 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -4425,19 +4425,74 @@ components: properties: type: type: string - enum: [ api-key, other, none ] + enum: [ api-key, oauth2, other, none ] + description: > + "api-key" attaches the built-in set-headers policy by + default (overridable via policyName) and accepts either the + generic policyParams bucket or its own deprecated header/value + fields below. "oauth2" attaches the built-in oauth2-generator + policy by default (overridable via policyName) and always + requires policyParams - there is no typed-field fallback for + it. "other" attaches any policy by name - policyName and + policyParams are both required in that case, since there is + no built-in default or typed-field fallback for a + non-built-in auth scheme. "none": no upstream authentication - + the gateway attaches no auth policy of its own; auth (if any) + is handled entirely by user-attached policies elsewhere. + policyName: + type: string + description: > + Name of the policy that implements this upstream auth. + Optional for "api-key"/"oauth2" (defaults to the built-in + policy for that type - api-key -> set-headers, oauth2 -> + oauth2-generator); set it to point at your own fork or a + newer major version's replacement instead. Required when + type is "other". + policyVersion: + type: string + pattern: '^v\d+$' + description: > + Major version of policyName to attach (e.g. "v1"), same + format and resolution rules as Policy.version. Optional - + defaults to the highest version available in the gateway + image when omitted. If set, it must match a version + actually loaded in this gateway build, or config validation + fails. + policyParams: + type: object + additionalProperties: true + description: > + Parameters passed verbatim to policyName (or the built-in + default for type). Required when type is "oauth2" or + "other" - oauth2 has no typed fields at all, only this + bucket (e.g. {tokenEndpoint: ..., clientId: ..., + clientSecret: ...} for the token-endpoint path, or + {bearerToken: ...} for a directly-supplied credential). + For "api-key", optional: replaces the deprecated header/value + fields below when set; do not set both at once. header: type: string + deprecated: true + description: > + Deprecated: use policyParams (e.g. {request: {headers: + [{name: ..., value: ...}]}} - the set-headers policy's own + param shape) instead. HTTP header to set on outbound + requests. Applies when type is api-key. Still honored when + policyParams is omitted, for backward compatibility. value: type: string + deprecated: true writeOnly: true description: > - Upstream credential. Write-only: accepted on create/update and - never returned by the management API on a read, for any role. - Supply either a literal value or a secret reference (e.g. a - `secret` template expression); either way the field is omitted - from management API response bodies. An update that omits it - inherits the stored value; set `type: none` to remove auth. + Deprecated: use policyParams instead. Upstream credential. + Applies when type is api-key. Still honored when policyParams + is omitted, for backward compatibility. Write-only: accepted + on create/update and never returned by the management API on + a read, for any role. Supply either a literal value or a + secret reference (e.g. a `secret` template expression); + either way the field is omitted from management API response + bodies. An update that omits it inherits the stored value; + set `type: none` to remove auth. LLMUpstreamAuth: type: object @@ -4446,16 +4501,69 @@ components: properties: type: type: string - enum: [ api-key, other, none ] + enum: [ api-key, oauth2, other, none ] + description: > + "api-key" attaches the built-in set-headers policy by default + (overridable via policyName) and accepts either the generic + policyParams bucket or its own deprecated header/value fields + below. "oauth2" attaches the built-in oauth2-generator policy + by default (overridable via policyName) and always requires + policyParams - there is no typed-field fallback for it. "other" + attaches any policy by name - policyName and policyParams are + both required in that case, since there is no built-in default + or typed-field fallback for a non-built-in auth scheme. "none": + no upstream authentication - the gateway attaches no auth policy + of its own; auth (if any) is handled entirely by user-attached + policies elsewhere. + policyName: + type: string + description: > + Name of the policy that implements this upstream auth. Optional + for "api-key"/"oauth2" (defaults to the built-in policy for that + type - api-key -> set-headers, oauth2 -> oauth2-generator); set + it to point at your own fork or a newer major version's + replacement instead. Required when type is "other". + policyVersion: + type: string + pattern: '^v\d+$' + description: > + Major version of policyName to attach (e.g. "v1"), same format + and resolution rules as Policy.version. Optional - defaults to + the highest version available in the gateway image when + omitted. If set, it must match a version actually loaded in + this gateway build, or config validation fails. + policyParams: + type: object + additionalProperties: true + description: > + Parameters passed verbatim to policyName (or the built-in + default for type). Required when type is "oauth2" or "other" - + oauth2 has no typed fields at all, only this bucket (e.g. + {tokenEndpoint: ..., clientId: ..., clientSecret: ...} for the + token-endpoint path, or {bearerToken: ...} for a + directly-supplied credential). For "api-key", optional: + replaces the deprecated header/value fields below when set; do + not set both at once. header: type: string + deprecated: true + description: > + Deprecated: use policyParams (e.g. {request: {headers: [{name: + ..., value: ...}]}} - the set-headers policy's own param shape) + instead. HTTP header to set on outbound requests. Applies when + type is api-key. Still honored when policyParams is omitted, + for backward compatibility. value: type: string + deprecated: true writeOnly: true description: > - Upstream credential. Write-only: accepted on create/update and never - returned by the management API on a read, for any role. An update that - omits it inherits the stored value; set `type: none` to remove auth. + Deprecated: use policyParams instead. Upstream credential. + Applies when type is api-key. Still honored when policyParams is + omitted, for backward compatibility. Write-only: accepted on + create/update and never returned by the management API on a + read, for any role. An update that omits it inherits the stored + value; set `type: none` to remove auth. LLMProxyProvider: type: object diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 7e473ca5a1..a8df66e1bb 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -337,6 +337,9 @@ func main() { policyDefinitions[key] = def } + // Built early so the startup rehydration below can use it too. + policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) + // MCP proxies and LLM artifacts are stored in source form and need to be // rehydrated into their derived RestAPI representations before startup // snapshot and policy work. @@ -345,6 +348,7 @@ func main() { db, &cfg.Router, policyDefinitions, + policyVersionResolver, log, cfg.Controller.Server.SkipInvalidDeploymentsOnStartup, ); err != nil { @@ -427,7 +431,6 @@ func main() { policyManager.SetRuntimeStore(runtimeStore) // Build transformer registry for StoredConfig → RuntimeDeployConfig conversion - policyVersionResolver := utils.NewLoadedPolicyVersionResolver(policyDefinitions) restTransformer := transform.NewRestAPITransformer(&cfg.Router, cfg, policyDefinitions) llmTransformer := transform.NewLLMTransformer(configStore, db, &cfg.Router, cfg, policyDefinitions, policyVersionResolver) transformerRegistry := transform.NewRegistry(restTransformer, llmTransformer) @@ -520,7 +523,7 @@ func main() { validator.SetPolicyValidator(policyValidator) apiSvc := utils.NewAPIDeploymentService(configStore, db, snapshotManager, validator, &cfg.Router, eventHubInstance, gatewayID, secretsService) - mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService) + mcpSvc := utils.NewMCPDeploymentService(configStore, db, snapshotManager, policyManager, policyValidator, eventHubInstance, gatewayID, secretsService, policyVersionResolver) llmSvc := utils.NewLLMDeploymentService(configStore, db, snapshotManager, lazyResourceXDSManager, templateDefinitions, apiSvc, &cfg.Router, policyVersionResolver, policyValidator) @@ -604,6 +607,7 @@ func main() { cfg, policyDefinitions, secretsService, + policyVersionResolver, ) if err := evtListener.Start(); err != nil { log.Error("Failed to start event listener", slog.Any("error", err)) diff --git a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go b/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go index 5d7de3fed8..d6155c3450 100644 --- a/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go +++ b/gateway/gateway-controller/cmd/controller/runtime_bootstrap.go @@ -18,6 +18,7 @@ func hydrateStoredConfigsFromDatabaseOnStartup( db storage.Storage, routerConfig *config.RouterConfig, policyDefinitions map[string]models.PolicyDefinition, + policyVersionResolver utils.PolicyVersionResolver, log *slog.Logger, skipInvalidDeployments bool, ) error { @@ -30,7 +31,9 @@ func hydrateStoredConfigsFromDatabaseOnStartup( "stored MCP proxy configuration", log, skipInvalidDeployments, - utils.HydrateStoredMCPConfig, + func(cfg *models.StoredConfig) error { + return utils.HydrateStoredMCPConfig(cfg, policyVersionResolver) + }, ); err != nil { return err } diff --git a/gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go b/gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go index 54b1f5dc1e..35c5f4e5e2 100644 --- a/gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go +++ b/gateway/gateway-controller/cmd/controller/runtime_bootstrap_test.go @@ -206,6 +206,7 @@ func TestHydrateStoredConfigsFromDatabaseOnStartup_FailsFastByDefault(t *testing nil, nil, nil, + nil, newDiscardLogger(), false, ) @@ -230,6 +231,7 @@ func TestHydrateStoredConfigsFromDatabaseOnStartup_SkipsInvalidConfigsWhenEnable nil, nil, nil, + nil, newDiscardLogger(), true, ) diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers.go b/gateway/gateway-controller/pkg/api/handlers/handlers.go index 0507254d29..fb1e39ac01 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers.go @@ -123,7 +123,7 @@ func NewAPIServer( parser := config.NewParser() httpClient := &http.Client{Timeout: 10 * time.Second} routerConfig := &systemConfig.Router - mcpDeploymentService := utils.NewMCPDeploymentService(store, db, snapshotManager, policyManager, policyValidator, eventHub, gatewayID, secretService) + mcpDeploymentService := utils.NewMCPDeploymentService(store, db, snapshotManager, policyManager, policyValidator, eventHub, gatewayID, secretService, policyVersionResolver) server := &APIServer{ store: store, diff --git a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go index f44f7163b4..4297c5f802 100644 --- a/gateway/gateway-controller/pkg/api/handlers/handlers_test.go +++ b/gateway/gateway-controller/pkg/api/handlers/handlers_test.go @@ -1142,7 +1142,7 @@ func createTestAPIServerWithDB(db storage.Storage) *APIServer { deploymentService := utils.NewAPIDeploymentService(store, db, nil, validator, routerCfg, hub, gatewayID, nil) server.deploymentService = deploymentService - server.mcpDeploymentService = utils.NewMCPDeploymentService(store, db, nil, nil, nil, hub, gatewayID, nil) + server.mcpDeploymentService = utils.NewMCPDeploymentService(store, db, nil, nil, nil, hub, gatewayID, nil, nil) server.llmDeploymentService = utils.NewLLMDeploymentService( store, db, @@ -1367,7 +1367,7 @@ func createTestMCPStoredConfig(t *testing.T, id, handle, displayName, version, c UpdatedAt: time.Now(), } - require.NoError(t, utils.HydrateStoredMCPConfig(cfg)) + require.NoError(t, utils.HydrateStoredMCPConfig(cfg, nil)) return cfg } @@ -1382,7 +1382,7 @@ func attachTestEventHub(server *APIServer, hub eventhub.EventHub, gatewayID stri server.deploymentService = utils.NewAPIDeploymentService(server.store, server.db, server.snapshotManager, server.validator, server.routerConfig, hub, gatewayID, nil) server.apiKeyService = utils.NewAPIKeyService(server.store, server.db, server.apiKeyXDSManager, &server.systemConfig.APIKey, hub, gatewayID) server.subscriptionResourceService = utils.NewSubscriptionResourceService(server.db, server.subscriptionSnapshotUpdater, hub, gatewayID) - server.mcpDeploymentService = utils.NewMCPDeploymentService(server.store, server.db, server.snapshotManager, server.policyManager, policyValidator, hub, gatewayID, nil) + server.mcpDeploymentService = utils.NewMCPDeploymentService(server.store, server.db, server.snapshotManager, server.policyManager, policyValidator, hub, gatewayID, nil, policyVersionResolver) server.llmDeploymentService = utils.NewLLMDeploymentService( server.store, server.db, diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index c2c071374e..98463683a2 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -107,6 +107,7 @@ const ( const ( LLMProviderConfigDataUpstreamAuthTypeApiKey LLMProviderConfigDataUpstreamAuthType = "api-key" LLMProviderConfigDataUpstreamAuthTypeNone LLMProviderConfigDataUpstreamAuthType = "none" + LLMProviderConfigDataUpstreamAuthTypeOauth2 LLMProviderConfigDataUpstreamAuthType = "oauth2" LLMProviderConfigDataUpstreamAuthTypeOther LLMProviderConfigDataUpstreamAuthType = "other" ) @@ -186,6 +187,7 @@ const ( const ( LLMUpstreamAuthTypeApiKey LLMUpstreamAuthType = "api-key" LLMUpstreamAuthTypeNone LLMUpstreamAuthType = "none" + LLMUpstreamAuthTypeOauth2 LLMUpstreamAuthType = "oauth2" LLMUpstreamAuthTypeOther LLMUpstreamAuthType = "other" ) @@ -199,6 +201,7 @@ const ( const ( MCPProxyConfigDataUpstreamAuthTypeApiKey MCPProxyConfigDataUpstreamAuthType = "api-key" MCPProxyConfigDataUpstreamAuthTypeNone MCPProxyConfigDataUpstreamAuthType = "none" + MCPProxyConfigDataUpstreamAuthTypeOauth2 MCPProxyConfigDataUpstreamAuthType = "oauth2" MCPProxyConfigDataUpstreamAuthTypeOther MCPProxyConfigDataUpstreamAuthType = "other" ) @@ -403,6 +406,7 @@ const ( const ( UpstreamAuthAuthTypeApiKey UpstreamAuthAuthType = "api-key" UpstreamAuthAuthTypeNone UpstreamAuthAuthType = "none" + UpstreamAuthAuthTypeOauth2 UpstreamAuthAuthType = "oauth2" UpstreamAuthAuthTypeOther UpstreamAuthAuthType = "other" ) @@ -773,7 +777,7 @@ type LLMProviderConfigData struct { // LLMProviderConfigDataDeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the LLM Provider is removed from router traffic but configuration and policies are preserved for potential redeployment. type LLMProviderConfigDataDeploymentState string -// LLMProviderConfigDataUpstreamAuthType defines model for LLMProviderConfigData.Upstream.Auth.Type. +// LLMProviderConfigDataUpstreamAuthType "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. type LLMProviderConfigDataUpstreamAuthType string // LLMProviderConfigDataUpstreamHostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. @@ -788,10 +792,24 @@ type LLMProviderConfigDataUpstream1 = interface{} // LLMProviderConfigData_Upstream defines model for LLMProviderConfigData.Upstream. type LLMProviderConfigData_Upstream struct { Auth *struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + // Header Deprecated: use policyParams (e.g. {request: {headers: [{name: ..., value: ...}]}} - the set-headers policy's own param shape) instead. HTTP header to set on outbound requests. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set + Header *string `json:"header,omitempty" yaml:"header,omitempty"` - // Value Upstream credential. Write-only: accepted on create/update and never returned by the management API on a read, for any role. Supply either a literal value or a secret reference (e.g. a `secret` template expression); either way the field is omitted from management API response bodies. An update that omits it inherits the stored value; set `type: none` to remove auth. + // PolicyName Name of the policy that implements this upstream auth. Optional for "api-key"/"oauth2" (defaults to the built-in policy for that type - api-key -> set-headers, oauth2 -> oauth2-generator); set it to point at your own fork or a newer major version's replacement instead. Required when type is "other". + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + + // PolicyParams Parameters passed verbatim to policyName (or the built-in default for type). Required when type is "oauth2" or "other" - oauth2 has no typed fields at all, only this bucket (e.g. {tokenEndpoint: ..., clientId: ..., clientSecret: ...} for the token-endpoint path, or {bearerToken: ...} for a directly-supplied credential). For "api-key", optional: replaces the deprecated header/value fields below when set; do not set both at once. + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + + // PolicyVersion Major version of policyName to attach (e.g. "v1"), same format and resolution rules as Policy.version. Optional - defaults to the highest version available in the gateway image when omitted. If set, it must match a version actually loaded in this gateway build, or config validation fails. + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + + // Type "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. + Type LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + + // Value Deprecated: use policyParams instead. Upstream credential. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. Write-only: accepted on create/update and never returned by the management API on a read, for any role. Supply either a literal value or a secret reference (e.g. a `secret` template expression); either way the field is omitted from management API response bodies. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Value *string `json:"value,omitempty" yaml:"value,omitempty"` } `json:"auth,omitempty" yaml:"auth,omitempty"` @@ -1066,14 +1084,28 @@ type LLMProxyTransformer struct { // LLMUpstreamAuth defines model for LLMUpstreamAuth. type LLMUpstreamAuth struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type LLMUpstreamAuthType `json:"type" yaml:"type"` + // Header Deprecated: use policyParams (e.g. {request: {headers: [{name: ..., value: ...}]}} - the set-headers policy's own param shape) instead. HTTP header to set on outbound requests. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + + // PolicyName Name of the policy that implements this upstream auth. Optional for "api-key"/"oauth2" (defaults to the built-in policy for that type - api-key -> set-headers, oauth2 -> oauth2-generator); set it to point at your own fork or a newer major version's replacement instead. Required when type is "other". + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + + // PolicyParams Parameters passed verbatim to policyName (or the built-in default for type). Required when type is "oauth2" or "other" - oauth2 has no typed fields at all, only this bucket (e.g. {tokenEndpoint: ..., clientId: ..., clientSecret: ...} for the token-endpoint path, or {bearerToken: ...} for a directly-supplied credential). For "api-key", optional: replaces the deprecated header/value fields below when set; do not set both at once. + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + + // PolicyVersion Major version of policyName to attach (e.g. "v1"), same format and resolution rules as Policy.version. Optional - defaults to the highest version available in the gateway image when omitted. If set, it must match a version actually loaded in this gateway build, or config validation fails. + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + + // Type "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. + Type LLMUpstreamAuthType `json:"type" yaml:"type"` - // Value Upstream credential. Write-only: accepted on create/update and never returned by the management API on a read, for any role. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Value Deprecated: use policyParams instead. Upstream credential. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. Write-only: accepted on create/update and never returned by the management API on a read, for any role. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Value *string `json:"value,omitempty" yaml:"value,omitempty"` } -// LLMUpstreamAuthType defines model for LLMUpstreamAuth.Type. +// LLMUpstreamAuthType "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. type LLMUpstreamAuthType string // MCPPrompt defines model for MCPPrompt. @@ -1142,7 +1174,7 @@ type MCPProxyConfigData struct { // MCPProxyConfigDataDeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the MCP Proxy is removed from router traffic but configuration and policies are preserved for potential redeployment. type MCPProxyConfigDataDeploymentState string -// MCPProxyConfigDataUpstreamAuthType defines model for MCPProxyConfigData.Upstream.Auth.Type. +// MCPProxyConfigDataUpstreamAuthType "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. type MCPProxyConfigDataUpstreamAuthType string // MCPProxyConfigDataUpstreamHostRewrite Controls how the Host header is handled when routing to the upstream. `auto` delegates host rewriting to Envoy, which rewrites the Host header using the upstream cluster host. `manual` disables automatic rewriting and expects explicit configuration. @@ -1157,10 +1189,24 @@ type MCPProxyConfigDataUpstream1 = interface{} // MCPProxyConfigData_Upstream defines model for MCPProxyConfigData.Upstream. type MCPProxyConfigData_Upstream struct { Auth *struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + // Header Deprecated: use policyParams (e.g. {request: {headers: [{name: ..., value: ...}]}} - the set-headers policy's own param shape) instead. HTTP header to set on outbound requests. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + + // PolicyName Name of the policy that implements this upstream auth. Optional for "api-key"/"oauth2" (defaults to the built-in policy for that type - api-key -> set-headers, oauth2 -> oauth2-generator); set it to point at your own fork or a newer major version's replacement instead. Required when type is "other". + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + + // PolicyParams Parameters passed verbatim to policyName (or the built-in default for type). Required when type is "oauth2" or "other" - oauth2 has no typed fields at all, only this bucket (e.g. {tokenEndpoint: ..., clientId: ..., clientSecret: ...} for the token-endpoint path, or {bearerToken: ...} for a directly-supplied credential). For "api-key", optional: replaces the deprecated header/value fields below when set; do not set both at once. + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + + // PolicyVersion Major version of policyName to attach (e.g. "v1"), same format and resolution rules as Policy.version. Optional - defaults to the highest version available in the gateway image when omitted. If set, it must match a version actually loaded in this gateway build, or config validation fails. + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + + // Type "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. + Type MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` - // Value Upstream credential. Write-only: accepted on create/update and never returned by the management API on a read, for any role. Supply either a literal value or a secret reference (e.g. a `secret` template expression); either way the field is omitted from management API response bodies. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Value Deprecated: use policyParams instead. Upstream credential. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. Write-only: accepted on create/update and never returned by the management API on a read, for any role. Supply either a literal value or a secret reference (e.g. a `secret` template expression); either way the field is omitted from management API response bodies. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Value *string `json:"value,omitempty" yaml:"value,omitempty"` } `json:"auth,omitempty" yaml:"auth,omitempty"` @@ -1698,15 +1744,29 @@ type Upstream1 = interface{} // UpstreamAuth defines model for UpstreamAuth. type UpstreamAuth struct { Auth *struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type UpstreamAuthAuthType `json:"type" yaml:"type"` + // Header Deprecated: use policyParams (e.g. {request: {headers: [{name: ..., value: ...}]}} - the set-headers policy's own param shape) instead. HTTP header to set on outbound requests. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + + // PolicyName Name of the policy that implements this upstream auth. Optional for "api-key"/"oauth2" (defaults to the built-in policy for that type - api-key -> set-headers, oauth2 -> oauth2-generator); set it to point at your own fork or a newer major version's replacement instead. Required when type is "other". + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + + // PolicyParams Parameters passed verbatim to policyName (or the built-in default for type). Required when type is "oauth2" or "other" - oauth2 has no typed fields at all, only this bucket (e.g. {tokenEndpoint: ..., clientId: ..., clientSecret: ...} for the token-endpoint path, or {bearerToken: ...} for a directly-supplied credential). For "api-key", optional: replaces the deprecated header/value fields below when set; do not set both at once. + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + + // PolicyVersion Major version of policyName to attach (e.g. "v1"), same format and resolution rules as Policy.version. Optional - defaults to the highest version available in the gateway image when omitted. If set, it must match a version actually loaded in this gateway build, or config validation fails. + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + + // Type "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. + Type UpstreamAuthAuthType `json:"type" yaml:"type"` - // Value Upstream credential. Write-only: accepted on create/update and never returned by the management API on a read, for any role. Supply either a literal value or a secret reference (e.g. a `secret` template expression); either way the field is omitted from management API response bodies. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Value Deprecated: use policyParams instead. Upstream credential. Applies when type is api-key. Still honored when policyParams is omitted, for backward compatibility. Write-only: accepted on create/update and never returned by the management API on a read, for any role. Supply either a literal value or a secret reference (e.g. a `secret` template expression); either way the field is omitted from management API response bodies. An update that omits it inherits the stored value; set `type: none` to remove auth. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set Value *string `json:"value,omitempty" yaml:"value,omitempty"` } `json:"auth,omitempty" yaml:"auth,omitempty"` } -// UpstreamAuthAuthType defines model for UpstreamAuth.Auth.Type. +// UpstreamAuthAuthType "api-key" attaches the built-in set-headers policy by default (overridable via policyName) and accepts either the generic policyParams bucket or its own deprecated header/value fields below. "oauth2" attaches the built-in oauth2-generator policy by default (overridable via policyName) and always requires policyParams - there is no typed-field fallback for it. "other" attaches any policy by name - policyName and policyParams are both required in that case, since there is no built-in default or typed-field fallback for a non-built-in auth scheme. "none": no upstream authentication - the gateway attaches no auth policy of its own; auth (if any) is handled entirely by user-attached policies elsewhere. type UpstreamAuthAuthType string // UpstreamDefinition Reusable upstream configuration with optional timeout and load balancing settings @@ -4601,270 +4661,283 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/22YsUw64QEA", + "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", + "F51ytmPKQKJi1aZJir4cUTWBhCr5VMSm+aiUIXFEGrirvkrW5Ej9vHvWdFfabfEuhFXotuUR7zX9c3HN", + "9lE5wcyd70+Gxa7V22Wdd49St91ixFjx9Z1qjrN3Ck7I27gl8iG/fr022+CvQ4BkR7diF4l73EcuTFgs", + "1m6Sr09rz/jdQzDta9qwqYl88YHKBwDGRYsHrqeomPr/cIUD19OHqRq4nj7KkoFHUTAgz+RrqxYwtLxA", + "rcD19MELBQDqp1AmoNlQiQ9fT++8QuB66i4PuJ4uUhuQJ3yXWXdeM1C6paR5OcD19E5rAUpouspsnNqh", + "6/SL6+njKQGokO8sqNfJ/8sm/19Pv8LMfyDZlTGzkkq5ePb/9XTB1P/r6W3TFWGEcoW9Zx48jc43GbgL", + "JfmD5HjYDP86EB7IaryePrXc/tXSb6MM/+tpo/T+6+kqcvsfO3UuI51Xrq7MI7AHzeN/9DRlJfEr1E7L", + "OLlifX+xLH6laTZO4X8iAvGrthFK6fqZWXSfufoLsYh1lv6T41qzGMZdq/S3T9NvwNQsz+90BQn619P5", + "2flPSrt4Wln5T0ILaJCSf3viWlUyfgMSKvrmbh/rVjQ0Nwf/qWgM69z7de79rZjYOjNp5Yn3K+WvM3WX", + "R5twvxpOfbcc+XYp9tfTdX79mqnmTPWrSa5ftXb4MGn1XxMDcifS3yUDWmfRr7PoHxsjXSuqq02hfyAt", + "dfWp8w2cCOW8+a9LPa3LlH+KEmKdJr9Ok/+qle85OfIr58oTP2mWHX+4d3S08uT4mOm8aXdsJJ+zeVb8", + "4d5RMSu+2k//UL11ZPPi1efE54Dcb058Pm99Tjy5JGwqxnKsrzMv/q4z01+6MtMnfnK0YHK6xvAHTE63", + "aOxR56YXeIHhgBkZ311qujmhcmZ6TSTKvH5HWeJOfFmNIjRn6HuN7tSQRRWFstNZ34faNM07p5mvKNXb", + "IruV8YaSerRApneGlU0TvS3wb3W1Wr7m7LbTzqCoeOSi35OLs/WQR5wD7oa6WSp4dhoPlgk+G4L7tosy", + "aJ5GHvid0PbsLPBsh2YngZvXbnV7aZlynwq9LiO+V66ezCG2h0kKfyL0JXG9gOjBihXrhjngGQzNUsDv", + "RFQqR/29kt5fzDboPqBtsL6P9GvgVzNYx6q1fka48HBC57hEjwkXvaP+PTpEzYzN3aG9o369I/SYYKiG", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/config/llm_validator.go b/gateway/gateway-controller/pkg/config/llm_validator.go index 86ccf284e7..eb489aa8c1 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator.go +++ b/gateway/gateway-controller/pkg/config/llm_validator.go @@ -535,6 +535,101 @@ func (v *LLMValidator) validatePolicyListExclusivity(globalPolicies *[]api.Polic return nil } +// upstreamAuthFields normalizes the upstream-auth shapes shared by +// LlmProvider, LlmProxy, and MCPProxy for one shared set of validation rules. +type upstreamAuthFields struct { + authType string + header *string + value *string + + // Generic override/bucket fields; see validateUpstreamAuthFields for per-type rules. + policyName *string + policyVersion *string + policyParams *map[string]interface{} +} + +// validateUpstreamAuthFields is shared validation for LlmProvider/LlmProxy/MCPProxy +// upstream auth. fieldPrefix must already include the trailing ".auth" segment. +func validateUpstreamAuthFields(fieldPrefix string, f upstreamAuthFields) []ValidationError { + var errors []ValidationError + + if f.authType == "" { + return append(errors, ValidationError{ + Field: fieldPrefix + ".type", + Message: "Auth type is required", + }) + } + if f.authType != "api-key" && f.authType != "oauth2" && f.authType != "other" && f.authType != "none" { + return append(errors, ValidationError{ + Field: fieldPrefix + ".type", + Message: "Auth type must be 'api-key', 'oauth2', 'other', or 'none'", + }) + } + + // "none": authentication is handled by a user-attached policy elsewhere, + // or not at all - no field is required. + if f.authType == "none" { + return errors + } + + if f.policyVersion != nil && *f.policyVersion != "" && !majorVersionPattern.MatchString(*f.policyVersion) { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".policyVersion", + Message: "Auth policyVersion must be major-only (e.g. 'v1')", + }) + } + + // An explicitly empty policyParams ({}) counts as omitted - otherwise it + // satisfies every "is it present" check below with no real configuration. + hasPolicyParams := f.policyParams != nil && len(*f.policyParams) > 0 + + // "oauth2"/"other" have no typed fields - policyParams is mandatory for + // both, and "other" additionally requires an explicit policyName. + if f.authType == "oauth2" || f.authType == "other" { + if f.authType == "other" && (f.policyName == nil || strings.TrimSpace(*f.policyName) == "") { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".policyName", + Message: "Auth policyName is required when auth type is 'other'", + }) + } + if !hasPolicyParams { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".policyParams", + Message: fmt.Sprintf("Auth policyParams is required when auth type is '%s'", f.authType), + }) + } + return errors + } + + // type is api-key: header/value (deprecated) and policyParams are mutually + // exclusive - configuring both is ambiguous, so it's rejected outright. + hasLegacyApiKeyFields := (f.header != nil && *f.header != "") || (f.value != nil && *f.value != "") + if hasPolicyParams && hasLegacyApiKeyFields { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".policyParams", + Message: "Auth policyParams cannot be combined with the deprecated 'header'/'value' fields - configure one or the other", + }) + } + if hasPolicyParams { + return errors + } + + if f.header == nil || *f.header == "" { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".header", + Message: "Auth header is required when api-key auth type is set and policyParams is omitted", + }) + } + if f.value == nil || *f.value == "" { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".value", + Message: "Auth value is required when api-key auth type is set and policyParams is omitted", + }) + } + + return errors +} + // validateUpstreamWithAuth validates an UpstreamWithAuth configuration. The upstream may specify // either a direct `url` or a `ref` to one of the provided upstream definitions (exactly one). func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, @@ -594,37 +689,15 @@ func (v *LLMValidator) validateUpstreamWithAuth(fieldPrefix string, // Validate auth if present if upstream.Auth != nil { auth := upstream.Auth - // Validate 'type' - if auth.Type == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.type", fieldPrefix), - Message: "Auth type is required", - }) - } else if auth.Type != api.LLMProviderConfigDataUpstreamAuthTypeApiKey && - auth.Type != api.LLMProviderConfigDataUpstreamAuthTypeOther && - auth.Type != api.LLMProviderConfigDataUpstreamAuthTypeNone { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.type", fieldPrefix), - Message: "Auth type must be one of 'api-key', 'other', 'none'", - }) - } - - // Header and value are only meaningful for api-key; for 'other'/'none' - // authentication is handled by user-attached policies (or not at all). - if auth.Type == api.LLMProviderConfigDataUpstreamAuthTypeApiKey { - if auth.Header == nil || *auth.Header == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.header", fieldPrefix), - Message: "Auth header is required when api-key auth type is set", - }) - } - if auth.Value == nil || *auth.Value == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.value", fieldPrefix), - Message: "Auth value is required when api-key auth type is set", - }) - } + fields := upstreamAuthFields{ + authType: string(auth.Type), + header: auth.Header, + value: auth.Value, + policyName: auth.PolicyName, + policyVersion: auth.PolicyVersion, + policyParams: auth.PolicyParams, } + errors = append(errors, validateUpstreamAuthFields(fieldPrefix+".auth", fields)...) } return errors @@ -811,38 +884,17 @@ func (v *LLMValidator) validateLLMProxyTransformer(fieldPrefix string, transform return errors } +// validateLLMUpstreamAuth validates an LlmProxy provider/additionalProviders auth block (must be non-nil). func (v *LLMValidator) validateLLMUpstreamAuth(fieldPrefix string, auth *api.LLMUpstreamAuth) []ValidationError { - var errors []ValidationError - if auth.Type == "" { - errors = append(errors, ValidationError{ - Field: fieldPrefix + ".type", - Message: "Auth type is required", - }) - } else if auth.Type != api.LLMUpstreamAuthTypeApiKey && - auth.Type != api.LLMUpstreamAuthTypeOther && - auth.Type != api.LLMUpstreamAuthTypeNone { - errors = append(errors, ValidationError{ - Field: fieldPrefix + ".type", - Message: "Auth type must be one of 'api-key', 'other', 'none'", - }) - } - // Header and value are only meaningful for api-key; for 'other'/'none' - // authentication is handled by user-attached policies (or not at all). - if auth.Type == api.LLMUpstreamAuthTypeApiKey { - if auth.Header == nil || *auth.Header == "" { - errors = append(errors, ValidationError{ - Field: fieldPrefix + ".header", - Message: "Auth header is required when api-key auth type is set", - }) - } - if auth.Value == nil || *auth.Value == "" { - errors = append(errors, ValidationError{ - Field: fieldPrefix + ".value", - Message: "Auth value is required when api-key auth type is set", - }) - } - } - return errors + fields := upstreamAuthFields{ + authType: string(auth.Type), + header: auth.Header, + value: auth.Value, + policyName: auth.PolicyName, + policyVersion: auth.PolicyVersion, + policyParams: auth.PolicyParams, + } + return validateUpstreamAuthFields(fieldPrefix, fields) } // validateAccessControl validates access control configuration diff --git a/gateway/gateway-controller/pkg/config/llm_validator_test.go b/gateway/gateway-controller/pkg/config/llm_validator_test.go index 31fb49d954..1aa2642078 100644 --- a/gateway/gateway-controller/pkg/config/llm_validator_test.go +++ b/gateway/gateway-controller/pkg/config/llm_validator_test.go @@ -587,9 +587,12 @@ func TestValidateLLMProvider_Valid(t *testing.T) { Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.openai.com"), Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Header: stringPtr("Authorization"), @@ -1253,9 +1256,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { tests := []struct { name string auth *struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` } expectError bool errorField string @@ -1264,9 +1270,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "missing auth type", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: "", Header: stringPtr("Authorization"), @@ -1279,9 +1288,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "invalid auth type", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: "bearer", Header: stringPtr("Authorization"), @@ -1294,9 +1306,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "api-key without header", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Value: stringPtr("sk-test"), @@ -1308,9 +1323,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "api-key with empty header", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Header: stringPtr(""), @@ -1323,9 +1341,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "api-key without value", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Header: stringPtr("Authorization"), @@ -1337,9 +1358,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "api-key with empty value", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Header: stringPtr("Authorization"), @@ -1352,9 +1376,12 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { { name: "valid api-key auth", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Header: stringPtr("Authorization"), @@ -1363,27 +1390,164 @@ func TestValidateLLMProvider_UpstreamAuth(t *testing.T) { expectError: false, }, { - name: "valid other auth without header or value", + name: "oauth2 without policyParams", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeOther, + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + }, + expectError: true, + errorField: "spec.upstream.auth.policyParams", + errorPart: "required", + }, + { + name: "valid oauth2 auth via policyParams", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": "https://idp.example.com/oauth2/token", + "clientId": "client-id", + "clientSecret": "client-secret", + }, + }, + expectError: false, + }, + { + name: "oauth2 with policyName override and policyVersion", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyName: stringPtr("my-oauth2-fork"), + PolicyVersion: stringPtr("v2"), + PolicyParams: &map[string]interface{}{ + "bearerToken": "static-token", + }, }, expectError: false, }, { name: "valid none auth without header or value", auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeNone, }, expectError: false, }, + { + name: "invalid policyVersion format", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyVersion: stringPtr("v1.0.0"), + PolicyParams: &map[string]interface{}{ + "bearerToken": "static-token", + }, + }, + expectError: true, + errorField: "spec.upstream.auth.policyVersion", + errorPart: "major-only", + }, + { + name: "api-key with both header/value and policyParams", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, + Header: stringPtr("Authorization"), + Value: stringPtr("Bearer sk-test"), + PolicyParams: &map[string]interface{}{ + "request": map[string]interface{}{"headers": []interface{}{}}, + }, + }, + expectError: true, + errorField: "spec.upstream.auth.policyParams", + errorPart: "cannot be combined", + }, + { + name: "other without policyName", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOther, + PolicyParams: &map[string]interface{}{"foo": "bar"}, + }, + expectError: true, + errorField: "spec.upstream.auth.policyName", + errorPart: "required", + }, + { + name: "other without policyParams", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOther, + PolicyName: stringPtr("my-custom-auth-policy"), + }, + expectError: true, + errorField: "spec.upstream.auth.policyParams", + errorPart: "required", + }, + { + name: "valid other auth", + auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOther, + PolicyName: stringPtr("my-custom-auth-policy"), + PolicyParams: &map[string]interface{}{"foo": "bar"}, + }, + expectError: false, + }, } validator := NewLLMValidator() @@ -2020,6 +2184,79 @@ func TestValidateLLMProxy_Resilience(t *testing.T) { }) } +// validProxyWithAuth builds an LlmProxy whose primary provider.auth is set. +func validProxyWithAuth(auth *api.LLMUpstreamAuth) api.LLMProxyConfiguration { + return api.LLMProxyConfiguration{ + ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProxyConfigurationKindLlmProxy, + Metadata: api.Metadata{Name: "openai-proxy"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "my-proxy", + Version: "v1.0", + Provider: api.LLMProxyProvider{Id: "openai", Auth: auth}, + }, + } +} + +// TestValidateLLMProxy_ProviderAuth covers validateLLMUpstreamAuth, the LlmProxy-side +// counterpart to LlmProvider's upstream.auth handling. +func TestValidateLLMProxy_ProviderAuth(t *testing.T) { + validator := NewLLMValidator() + + t.Run("oauth2 with policyParams is valid", func(t *testing.T) { + errs := validator.Validate(validProxyWithAuth(&api.LLMUpstreamAuth{ + Type: "oauth2", + PolicyParams: &map[string]interface{}{"tokenEndpoint": "https://idp.example.com/token"}, + })) + assert.Empty(t, errs) + }) + + t.Run("oauth2 without policyParams is rejected", func(t *testing.T) { + errs := validator.Validate(validProxyWithAuth(&api.LLMUpstreamAuth{Type: "oauth2"})) + assertHasFieldError(t, errs, "spec.provider.auth.policyParams") + }) + + t.Run("other without policyName is rejected", func(t *testing.T) { + errs := validator.Validate(validProxyWithAuth(&api.LLMUpstreamAuth{ + Type: "other", + PolicyParams: &map[string]interface{}{"foo": "bar"}, + })) + assertHasFieldError(t, errs, "spec.provider.auth.policyName") + }) + + t.Run("nil auth is fine", func(t *testing.T) { + errs := validator.Validate(validProxyWithAuth(nil)) + assert.Empty(t, errs) + }) +} + +// TestValidateLLMProxy_AdditionalProviderAuth covers the second validateLLMUpstreamAuth +// call site: spec.additionalProviders[].auth. +func TestValidateLLMProxy_AdditionalProviderAuth(t *testing.T) { + validator := NewLLMValidator() + + proxyWithAdditional := func(auth *api.LLMUpstreamAuth) api.LLMProxyConfiguration { + p := validProxyWithAuth(nil) + p.Spec.AdditionalProviders = &[]api.LLMProxyAdditionalProvider{ + {Id: "anthropic", Auth: auth}, + } + return p + } + + t.Run("oauth2 with policyParams is valid", func(t *testing.T) { + errs := validator.Validate(proxyWithAdditional(&api.LLMUpstreamAuth{ + Type: "oauth2", + PolicyParams: &map[string]interface{}{"tokenEndpoint": "https://idp.example.com/token"}, + })) + assert.Empty(t, errs) + }) + + t.Run("oauth2 without policyParams is rejected on the additional provider's own field path", func(t *testing.T) { + errs := validator.Validate(proxyWithAdditional(&api.LLMUpstreamAuth{Type: "oauth2"})) + assertHasFieldError(t, errs, "spec.additionalProviders[0].auth.policyParams") + }) +} + // ============================================================================ // Upstream ref validation // ============================================================================ diff --git a/gateway/gateway-controller/pkg/config/mcp_validator.go b/gateway/gateway-controller/pkg/config/mcp_validator.go index bc33a651cf..edbcf09d7a 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator.go @@ -254,40 +254,59 @@ func (v *MCPValidator) validateUpstream(fieldPrefix string, upstream *api.MCPPro } } - // Validate auth if present + // Validate auth if present. Shared with LlmProvider/LlmProxy - see + // validateUpstreamAuthFields in llm_validator.go. if upstream.Auth != nil { auth := upstream.Auth - // Validate 'type' - if auth.Type == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.type", fieldPrefix), - Message: "Auth type is required", - }) - } - - if auth.Header == nil || *auth.Header == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.header", fieldPrefix), - Message: "Auth header is required", - }) - } - if auth.Value == nil || *auth.Value == "" { - errors = append(errors, ValidationError{ - Field: fmt.Sprintf("%s.auth.value", fieldPrefix), - Message: "Auth value is required", - }) - } + // "bearer" predates the shared api-key/oauth2/other/none contract - MCP-only, + // kept for backward compatibility (functionally api-key plus a value-prefix + // check), so it's validated separately rather than via the shared validator. if auth.Type == api.MCPProxyConfigDataUpstreamAuthType("bearer") { - // For Bearer token, value should start with "Bearer or "bearer " - if auth.Value != nil && - !strings.HasPrefix(*auth.Value, "Bearer ") && !strings.HasPrefix(*auth.Value, "bearer ") { + // policyParams has no meaning for bearer; reject rather than silently + // ignore, so a stray value can't fool credential-inheritance into + // skipping inheritance of the real Value-held credential. + if auth.PolicyParams != nil { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.auth.policyParams", fieldPrefix), + Message: "Auth policyParams is not supported when auth type is 'bearer'", + }) + } + if auth.PolicyVersion != nil && *auth.PolicyVersion != "" && !majorVersionPattern.MatchString(*auth.PolicyVersion) { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.auth.policyVersion", fieldPrefix), + Message: "Auth policyVersion must be major-only (e.g. 'v1')", + }) + } + if auth.Header == nil || *auth.Header == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.auth.header", fieldPrefix), + Message: "Auth header is required", + }) + } + if auth.Value == nil || *auth.Value == "" { + errors = append(errors, ValidationError{ + Field: fmt.Sprintf("%s.auth.value", fieldPrefix), + Message: "Auth value is required", + }) + } else if !strings.HasPrefix(*auth.Value, "Bearer ") && !strings.HasPrefix(*auth.Value, "bearer ") { errors = append(errors, ValidationError{ Field: fmt.Sprintf("%s.auth.value", fieldPrefix), Message: "Bearer token value must start with 'Bearer ' or 'bearer '", }) } + return errors + } + + fields := upstreamAuthFields{ + authType: string(auth.Type), + header: auth.Header, + value: auth.Value, + policyName: auth.PolicyName, + policyVersion: auth.PolicyVersion, + policyParams: auth.PolicyParams, } + errors = append(errors, validateUpstreamAuthFields(fieldPrefix+".auth", fields)...) } return errors diff --git a/gateway/gateway-controller/pkg/config/mcp_validator_test.go b/gateway/gateway-controller/pkg/config/mcp_validator_test.go index 190c80e87d..a09d8e2f19 100644 --- a/gateway/gateway-controller/pkg/config/mcp_validator_test.go +++ b/gateway/gateway-controller/pkg/config/mcp_validator_test.go @@ -482,9 +482,12 @@ func TestMCPValidator_ValidateUpstreamAuth(t *testing.T) { // Define auth struct type locally to match the anonymous struct in api package type authConfig struct { - Type api.MCPProxyConfigDataUpstreamAuthType - Header *string - Value *string + Type api.MCPProxyConfigDataUpstreamAuthType + Header *string + Value *string + PolicyName *string + PolicyVersion *string + PolicyParams *map[string]interface{} } tests := []struct { @@ -503,6 +506,8 @@ func TestMCPValidator_ValidateUpstreamAuth(t *testing.T) { wantError: false, }, { + // "bearer" predates the shared api-key/oauth2/other/none contract - + // kept for MCP backward compatibility, see mcp_validator.go. name: "Valid bearer auth", auth: &authConfig{ Type: api.MCPProxyConfigDataUpstreamAuthType("bearer"), @@ -521,6 +526,41 @@ func TestMCPValidator_ValidateUpstreamAuth(t *testing.T) { wantError: true, errField: "spec.upstream.auth.value", }, + { + // Regression: bearer has no policyParams form of its own - see mcp_validator.go. + name: "Bearer auth with policyParams is rejected", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthType("bearer"), + Header: stringPtr("Authorization"), + Value: stringPtr("Bearer token123"), + PolicyParams: &map[string]interface{}{"foo": "bar"}, + }, + wantError: true, + errField: "spec.upstream.auth.policyParams", + }, + { + // Regression: bearer previously bypassed the policyVersion format + // check every other auth type gets from validateUpstreamAuthFields. + name: "Bearer auth with malformed policyVersion is rejected", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthType("bearer"), + Header: stringPtr("Authorization"), + Value: stringPtr("Bearer token123"), + PolicyVersion: stringPtr("latest"), + }, + wantError: true, + errField: "spec.upstream.auth.policyVersion", + }, + { + name: "Unsupported auth type is rejected", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthType("unsupported-type"), + Header: stringPtr("Authorization"), + Value: stringPtr("secret"), + }, + wantError: true, + errField: "spec.upstream.auth.type", + }, { name: "Missing auth type", auth: &authConfig{ @@ -551,6 +591,53 @@ func TestMCPValidator_ValidateUpstreamAuth(t *testing.T) { wantError: true, errField: "spec.upstream.auth.value", }, + { + name: "Valid oauth2 auth via policyParams", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": "https://idp.example.com/oauth2/token", + "clientId": "client-id", + "clientSecret": "client-secret", + }, + }, + wantError: false, + }, + { + name: "oauth2 without policyParams", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOauth2, + }, + wantError: true, + errField: "spec.upstream.auth.policyParams", + }, + { + name: "other without policyName", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOther, + PolicyParams: &map[string]interface{}{"foo": "bar"}, + }, + wantError: true, + errField: "spec.upstream.auth.policyName", + }, + { + name: "other without policyParams", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOther, + PolicyName: stringPtr("my-custom-auth-policy"), + }, + wantError: true, + errField: "spec.upstream.auth.policyParams", + }, + { + name: "Valid other auth", + auth: &authConfig{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOther, + PolicyName: stringPtr("my-custom-auth-policy"), + PolicyParams: &map[string]interface{}{"foo": "bar"}, + }, + wantError: false, + }, } for _, tt := range tests { @@ -561,13 +648,19 @@ func TestMCPValidator_ValidateUpstreamAuth(t *testing.T) { } if tt.auth != nil { upstream.Auth = &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ - Type: tt.auth.Type, - Header: tt.auth.Header, - Value: tt.auth.Value, + Type: tt.auth.Type, + Header: tt.auth.Header, + Value: tt.auth.Value, + PolicyName: tt.auth.PolicyName, + PolicyVersion: tt.auth.PolicyVersion, + PolicyParams: tt.auth.PolicyParams, } } config := &api.MCPProxyConfiguration{ diff --git a/gateway/gateway-controller/pkg/constants/constants.go b/gateway/gateway-controller/pkg/constants/constants.go index 9b6967e527..296641ecaa 100644 --- a/gateway/gateway-controller/pkg/constants/constants.go +++ b/gateway/gateway-controller/pkg/constants/constants.go @@ -160,6 +160,7 @@ const ( " headers:\n" + " - name: '%s'\n" + " value: '%s'\n" + UPSTREAM_AUTH_OAUTH2_POLICY_NAME = "oauth2-generator" PROXY_HOST__HEADER_POLICY_NAME = "host-rewrite" PROXY_HOST__HEADER_POLICY_PARAMS = "host: '%s'\n" diff --git a/gateway/gateway-controller/pkg/controlplane/client.go b/gateway/gateway-controller/pkg/controlplane/client.go index e50480a59d..5dd8f93969 100644 --- a/gateway/gateway-controller/pkg/controlplane/client.go +++ b/gateway/gateway-controller/pkg/controlplane/client.go @@ -281,6 +281,7 @@ func NewClient( eventHubInstance, gatewayID, secretResolver, + policyVersionResolver, ) // Initialize API utils service with the proper base URL using the method diff --git a/gateway/gateway-controller/pkg/eventlistener/listener.go b/gateway/gateway-controller/pkg/eventlistener/listener.go index 44bac88788..847e9232b1 100644 --- a/gateway/gateway-controller/pkg/eventlistener/listener.go +++ b/gateway/gateway-controller/pkg/eventlistener/listener.go @@ -31,6 +31,7 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/policyxds" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/templateengine/funcs" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/xds" ) @@ -56,21 +57,22 @@ type SubscriptionSnapshotUpdater interface { // EventListener listens for events from EventHub and processes them // to keep the local replica synchronized with other replicas. type EventListener struct { - eventHub eventhub.EventHub - store *storage.ConfigStore - db storage.Storage - snapshotManager *xds.SnapshotManager - subscriptionManager SubscriptionSnapshotUpdater - apiKeyXDSManager APIKeyXDSManager - lazyResourceManager *lazyresourcexds.LazyResourceStateManager - policyManager *policyxds.PolicyManager - routerConfig *config.RouterConfig - logger *slog.Logger - systemConfig *config.Config - policyDefinitions map[string]models.PolicyDefinition - policyValidator *config.PolicyValidator - secretResolver funcs.SecretResolver - webhookSecretHandler WebhookSecretEventHandler + eventHub eventhub.EventHub + store *storage.ConfigStore + db storage.Storage + snapshotManager *xds.SnapshotManager + subscriptionManager SubscriptionSnapshotUpdater + apiKeyXDSManager APIKeyXDSManager + lazyResourceManager *lazyresourcexds.LazyResourceStateManager + policyManager *policyxds.PolicyManager + routerConfig *config.RouterConfig + logger *slog.Logger + systemConfig *config.Config + policyDefinitions map[string]models.PolicyDefinition + policyValidator *config.PolicyValidator + secretResolver funcs.SecretResolver + policyVersionResolver utils.PolicyVersionResolver + webhookSecretHandler WebhookSecretEventHandler eventCh <-chan eventhub.Event ctx context.Context @@ -92,6 +94,7 @@ func NewEventListener( systemConfig *config.Config, policyDefinitions map[string]models.PolicyDefinition, secretResolver funcs.SecretResolver, + policyVersionResolver utils.PolicyVersionResolver, ) *EventListener { if eventHub == nil { panic("event listener requires non-nil EventHub") @@ -112,22 +115,23 @@ func NewEventListener( ctx, cancel := context.WithCancel(context.Background()) return &EventListener{ - eventHub: eventHub, - store: store, - db: db, - snapshotManager: snapshotManager, - subscriptionManager: subscriptionManager, - apiKeyXDSManager: apiKeyXDSManager, - lazyResourceManager: lazyResourceManager, - policyManager: policyManager, - routerConfig: routerConfig, - logger: logger, - systemConfig: systemConfig, - policyDefinitions: policyDefinitions, - policyValidator: config.NewPolicyValidator(policyDefinitions), - secretResolver: secretResolver, - ctx: ctx, - cancel: cancel, + eventHub: eventHub, + store: store, + db: db, + snapshotManager: snapshotManager, + subscriptionManager: subscriptionManager, + apiKeyXDSManager: apiKeyXDSManager, + lazyResourceManager: lazyResourceManager, + policyManager: policyManager, + routerConfig: routerConfig, + logger: logger, + systemConfig: systemConfig, + policyDefinitions: policyDefinitions, + policyValidator: config.NewPolicyValidator(policyDefinitions), + secretResolver: secretResolver, + policyVersionResolver: policyVersionResolver, + ctx: ctx, + cancel: cancel, } } diff --git a/gateway/gateway-controller/pkg/eventlistener/listener_test.go b/gateway/gateway-controller/pkg/eventlistener/listener_test.go index a4f4feca37..822e75225c 100644 --- a/gateway/gateway-controller/pkg/eventlistener/listener_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/listener_test.go @@ -256,6 +256,7 @@ func TestNewEventListener_RequiresSystemConfig(t *testing.T) { nil, nil, nil, + nil, ) }) } @@ -276,6 +277,7 @@ func TestNewEventListener_RequiresGatewayID(t *testing.T) { &config.Config{Controller: config.Controller{}}, nil, nil, + nil, ) }) } @@ -302,6 +304,7 @@ func TestStart_SubscribesWithTrimmedGatewayID(t *testing.T) { }, nil, nil, + nil, ) require.NoError(t, listener.Start()) diff --git a/gateway/gateway-controller/pkg/eventlistener/mcp_processor.go b/gateway/gateway-controller/pkg/eventlistener/mcp_processor.go index b22aef3754..4eb15717cc 100644 --- a/gateway/gateway-controller/pkg/eventlistener/mcp_processor.go +++ b/gateway/gateway-controller/pkg/eventlistener/mcp_processor.go @@ -62,7 +62,7 @@ func (l *EventListener) handleMCPProxyCreateOrUpdate(event eventhub.Event) { slog.String("kind", storedConfig.Kind)) return } - if err := utils.HydrateStoredMCPConfig(storedConfig); err != nil { + if err := utils.HydrateStoredMCPConfig(storedConfig, l.policyVersionResolver); err != nil { l.logger.Error("Failed to hydrate MCP proxy configuration from source", slog.String("proxy_id", entityID), slog.Any("error", err)) diff --git a/gateway/gateway-controller/pkg/eventlistener/mcp_processor_test.go b/gateway/gateway-controller/pkg/eventlistener/mcp_processor_test.go index 36b202010e..9b3147ec43 100644 --- a/gateway/gateway-controller/pkg/eventlistener/mcp_processor_test.go +++ b/gateway/gateway-controller/pkg/eventlistener/mcp_processor_test.go @@ -67,7 +67,7 @@ func testMCPStoredConfig(uuid, handle, displayName, version string, desiredState CreatedAt: now, UpdatedAt: now, } - _ = utils.HydrateStoredMCPConfig(cfg) + _ = utils.HydrateStoredMCPConfig(cfg, nil) return cfg } diff --git a/gateway/gateway-controller/pkg/utils/commonutils.go b/gateway/gateway-controller/pkg/utils/commonutils.go index d71137e674..54566759b6 100644 --- a/gateway/gateway-controller/pkg/utils/commonutils.go +++ b/gateway/gateway-controller/pkg/utils/commonutils.go @@ -27,6 +27,8 @@ import ( "github.com/google/uuid" "gopkg.in/yaml.v3" + + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" ) // escapeParam escapes special characters in a parameter value to prevent @@ -53,6 +55,69 @@ func GetParamsOfPolicy(policyDef string, params ...string) (map[string]any, erro return m, nil } +// resolveUpstreamAuthPolicyName returns policyName if non-empty, otherwise defaultName. +func resolveUpstreamAuthPolicyName(policyName *string, defaultName string) string { + if policyName != nil && strings.TrimSpace(*policyName) != "" { + return strings.TrimSpace(*policyName) + } + return defaultName +} + +// resolveUpstreamAuthPolicyParams returns policyParams verbatim if supplied and +// non-empty, otherwise falls back to buildLegacyParams (the deprecated +// header/value path). An explicitly empty policyParams ({}) counts as omitted, +// matching validateUpstreamAuthFields's hasPolicyParams check. +func resolveUpstreamAuthPolicyParams(policyParams *map[string]interface{}, buildLegacyParams func() (map[string]interface{}, error)) (map[string]interface{}, error) { + if policyParams != nil && len(*policyParams) > 0 { + return *policyParams, nil + } + return buildLegacyParams() +} + +// buildUpstreamAuthPolicy builds the api.Policy for a policy-name-and-params-based upstream +// auth type (api-key/oauth2/other), shared by every transformer and auth type. Pass +// defaultPolicyName "" for a type with no built-in default (policyName required instead), +// and buildLegacyParams nil for a type with no header/value fallback (policyParams required +// instead) - the two are independent. fieldPrefix is the error-message field path; +// resolveVersion validates a caller-overridden policy version. +func buildUpstreamAuthPolicy( + authType, fieldPrefix string, + policyName, policyVersion *string, + policyParams *map[string]interface{}, + defaultPolicyName string, + buildLegacyParams func() (map[string]interface{}, error), + resolveVersion func(name string, override *string) (string, error), +) (*api.Policy, error) { + var name string + if defaultPolicyName == "" { + if policyName == nil || strings.TrimSpace(*policyName) == "" { + return nil, fmt.Errorf("%s.policyName is required when type is '%s'", fieldPrefix, authType) + } + name = strings.TrimSpace(*policyName) + } else { + name = resolveUpstreamAuthPolicyName(policyName, defaultPolicyName) + } + + var params map[string]interface{} + if buildLegacyParams == nil { + if policyParams == nil || len(*policyParams) == 0 { + return nil, fmt.Errorf("%s.policyParams is required when type is '%s'", fieldPrefix, authType) + } + params = *policyParams + } else { + var err error + if params, err = resolveUpstreamAuthPolicyParams(policyParams, buildLegacyParams); err != nil { + return nil, err + } + } + + version, err := resolveVersion(name, policyVersion) + if err != nil { + return nil, err + } + return &api.Policy{Name: name, Version: version, Params: ¶ms}, nil +} + // APIKeyETag produces a deterministic UUID v7-formatted ETag from the unique // (artifactUUID, name, updatedAt) tuple. Uses SHA-256 of the tuple as the source // bytes, then stamps version=7 and RFC 4122 variant bits. diff --git a/gateway/gateway-controller/pkg/utils/credential_inheritance.go b/gateway/gateway-controller/pkg/utils/credential_inheritance.go index 74b3ccc81c..88c9a4c71c 100644 --- a/gateway/gateway-controller/pkg/utils/credential_inheritance.go +++ b/gateway/gateway-controller/pkg/utils/credential_inheritance.go @@ -18,23 +18,21 @@ // Upstream credential inheritance on update. // -// An upstream `auth.value` is write-only: accepted on create/update, never -// returned on a read (see pkg/api/handlers/credential_redaction.go). An update -// that does not carry a credential therefore inherits the persisted one: +// An upstream credential is write-only - accepted on create/update, never +// returned on a read (see pkg/api/handlers/credential_redaction.go), via +// `value` for legacy auth types or `policyParams` for oauth2/other. An update +// that doesn't carry a credential inherits the persisted one instead: // -// value supplied -> use it -// auth present, same type, value omitted -> inherit the stored value -// auth omitted entirely -> inherit the stored auth block -// auth present, type changed -> no inheritance; supply a new value -// type: none -> no inheritance; auth is removed +// credential supplied -> use it +// auth present, same type, credential omitted -> inherit the stored credential +// auth omitted entirely -> inherit the stored auth block +// auth present, type changed -> no inheritance; supply a new credential +// type: none -> no inheritance; auth is removed // -// Inheritance reads the stored SourceConfiguration, which is the unrendered -// artifact, so a credential held as a `secret` expression is carried forward -// unresolved and re-rendered downstream like any other value. -// -// This applies to control-plane deploys as well as management API updates, so a -// declarative apply that intends to drop upstream auth must say `type: none` -// rather than omit the block. +// Inheritance reads the stored SourceConfiguration (the unrendered artifact), +// so a `secret` expression is carried forward unresolved. This applies to +// control-plane deploys too, so a declarative apply that means to drop +// upstream auth must say `type: none` rather than omit the block. package utils import ( @@ -132,6 +130,55 @@ func hasCredential(value *string) bool { return value != nil && *value != "" } +// hasAnyCredential reports whether an auth block carries credential material via +// either mechanism an auth type can use: legacy Value, or the policyParams bucket +// oauth2/other stores its secret in instead. A present-but-empty policyParams map +// counts as absent, like hasCredential's empty string - otherwise a client that +// always serializes the field as `{}` would have its stored credential silently +// dropped instead of inherited. +func hasAnyCredential(value *string, policyParams *map[string]interface{}) bool { + return hasCredential(value) || (policyParams != nil && len(*policyParams) > 0) +} + +// mergePolicyParams merges stored policyParams under incoming ones: every key +// present in incoming wins, and any key omitted from incoming but present in +// stored is carried forward - so rotating just tokenEndpoint doesn't drop a +// stored clientSecret it never touched. +func mergePolicyParams(incoming, stored *map[string]interface{}) *map[string]interface{} { + if stored == nil || len(*stored) == 0 { + return incoming + } + if incoming == nil || len(*incoming) == 0 { + return stored + } + merged := make(map[string]interface{}, len(*stored)+len(*incoming)) + for k, v := range *stored { + merged[k] = v + } + for k, v := range *incoming { + merged[k] = v + } + return &merged +} + +// inheritSameTypeCredential applies same-type inheritance to a Value/ +// PolicyParams pair, in place. Whichever mechanism incoming actually uses +// wins outright (Value present -> policyParams left alone, don't resurrect a +// stored one from a different mechanism and trip the validator's mutual- +// exclusivity check; policyParams present, even empty -> merge in omitted +// stored keys, don't touch Value). Neither supplied -> inherit both wholesale. +func inheritSameTypeCredential(incomingValue **string, incomingPolicyParams **map[string]interface{}, storedValue *string, storedPolicyParams *map[string]interface{}) { + switch { + case hasCredential(*incomingValue): + return + case *incomingPolicyParams != nil: + *incomingPolicyParams = mergePolicyParams(*incomingPolicyParams, storedPolicyParams) + default: + *incomingValue = storedValue + *incomingPolicyParams = storedPolicyParams + } +} + // inheritLLMProviderCredential carries a persisted upstream credential forward // when an LLM provider update omits it. func inheritLLMProviderCredential(incoming *api.LLMProviderConfiguration, storedSource any) { @@ -139,7 +186,8 @@ func inheritLLMProviderCredential(incoming *api.LLMProviderConfiguration, stored return } stored, ok := reinterpret[api.LLMProviderConfiguration](storedSource) - if !ok || stored.Spec.Upstream.Auth == nil || !hasCredential(stored.Spec.Upstream.Auth.Value) { + if !ok || stored.Spec.Upstream.Auth == nil || + !hasAnyCredential(stored.Spec.Upstream.Auth.Value, stored.Spec.Upstream.Auth.PolicyParams) { return } @@ -157,9 +205,10 @@ func inheritLLMProviderCredential(incoming *api.LLMProviderConfiguration, stored if incoming.Spec.Upstream.Auth.Type != stored.Spec.Upstream.Auth.Type { return } - if !hasCredential(incoming.Spec.Upstream.Auth.Value) { - incoming.Spec.Upstream.Auth.Value = stored.Spec.Upstream.Auth.Value - } + inheritSameTypeCredential( + &incoming.Spec.Upstream.Auth.Value, &incoming.Spec.Upstream.Auth.PolicyParams, + stored.Spec.Upstream.Auth.Value, stored.Spec.Upstream.Auth.PolicyParams, + ) } // inheritLLMProxyCredentials carries persisted upstream credentials forward when @@ -201,7 +250,7 @@ func inheritLLMProxyCredentials(incoming *api.LLMProxyConfiguration, storedSourc // inheritLLMUpstreamAuth applies the inheritance rules to a single // *api.LLMUpstreamAuth field, in place. func inheritLLMUpstreamAuth(incoming **api.LLMUpstreamAuth, stored *api.LLMUpstreamAuth) { - if incoming == nil || stored == nil || !hasCredential(stored.Value) { + if incoming == nil || stored == nil || !hasAnyCredential(stored.Value, stored.PolicyParams) { return } if *incoming == nil { @@ -215,9 +264,7 @@ func inheritLLMUpstreamAuth(incoming **api.LLMUpstreamAuth, stored *api.LLMUpstr if (*incoming).Type != stored.Type { return } - if !hasCredential((*incoming).Value) { - (*incoming).Value = stored.Value - } + inheritSameTypeCredential(&(*incoming).Value, &(*incoming).PolicyParams, stored.Value, stored.PolicyParams) } // inheritMCPProxyCredential carries a persisted upstream credential forward when @@ -227,7 +274,8 @@ func inheritMCPProxyCredential(incoming *api.MCPProxyConfiguration, storedSource return } stored, ok := reinterpret[api.MCPProxyConfiguration](storedSource) - if !ok || stored.Spec.Upstream.Auth == nil || !hasCredential(stored.Spec.Upstream.Auth.Value) { + if !ok || stored.Spec.Upstream.Auth == nil || + !hasAnyCredential(stored.Spec.Upstream.Auth.Value, stored.Spec.Upstream.Auth.PolicyParams) { return } @@ -242,7 +290,8 @@ func inheritMCPProxyCredential(incoming *api.MCPProxyConfiguration, storedSource if incoming.Spec.Upstream.Auth.Type != stored.Spec.Upstream.Auth.Type { return } - if !hasCredential(incoming.Spec.Upstream.Auth.Value) { - incoming.Spec.Upstream.Auth.Value = stored.Spec.Upstream.Auth.Value - } + inheritSameTypeCredential( + &incoming.Spec.Upstream.Auth.Value, &incoming.Spec.Upstream.Auth.PolicyParams, + stored.Spec.Upstream.Auth.Value, stored.Spec.Upstream.Auth.PolicyParams, + ) } diff --git a/gateway/gateway-controller/pkg/utils/credential_inheritance_test.go b/gateway/gateway-controller/pkg/utils/credential_inheritance_test.go index 37506e7b2e..d40819d1c8 100644 --- a/gateway/gateway-controller/pkg/utils/credential_inheritance_test.go +++ b/gateway/gateway-controller/pkg/utils/credential_inheritance_test.go @@ -41,13 +41,33 @@ func storedProvider() api.LLMProviderConfiguration { var cfg api.LLMProviderConfiguration cfg.Spec.Upstream.Url = sp("https://api.openai.com/v1") cfg.Spec.Upstream.Auth = &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{Header: sp("Authorization"), Type: "api-key", Value: sp(storedCred)} return cfg } +// storedOAuth2Provider builds a persisted provider whose credential lives in +// PolicyParams (oauth2/other auth), not Value. +func storedOAuth2Provider() api.LLMProviderConfiguration { + var cfg api.LLMProviderConfiguration + cfg.Spec.Upstream.Url = sp("https://api.openai.com/v1") + params := map[string]interface{}{"tokenEndpoint": "https://idp.example.com/token", "clientSecret": storedCred} + cfg.Spec.Upstream.Auth = &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{Type: "oauth2", PolicyParams: ¶ms} + return cfg +} + func TestInheritLLMProviderCredential(t *testing.T) { t.Run("auth omitted entirely inherits the stored block", func(t *testing.T) { var incoming api.LLMProviderConfiguration @@ -167,6 +187,67 @@ func TestInheritLLMProviderCredential(t *testing.T) { t.Run("nil incoming does not panic", func(t *testing.T) { assert.NotPanics(t, func() { inheritLLMProviderCredential(nil, storedProvider()) }) }) + + // Regression: oauth2/other stores its credential in PolicyParams, not Value. + t.Run("oauth2 auth omitted entirely inherits the stored policyParams", func(t *testing.T) { + var incoming api.LLMProviderConfiguration + incoming.Spec.Upstream.Url = sp("https://api.openai.com/v1") + + inheritLLMProviderCredential(&incoming, storedOAuth2Provider()) + + require.NotNil(t, incoming.Spec.Upstream.Auth, "stored oauth2 auth block should be inherited") + require.NotNil(t, incoming.Spec.Upstream.Auth.PolicyParams) + assert.Equal(t, storedCred, (*incoming.Spec.Upstream.Auth.PolicyParams)["clientSecret"]) + }) + + t.Run("oauth2 auth present with no policyParams inherits the stored policyParams", func(t *testing.T) { + incoming := storedOAuth2Provider() + incoming.Spec.Upstream.Auth.PolicyParams = nil // what a redacted GET would return + + inheritLLMProviderCredential(&incoming, storedOAuth2Provider()) + + require.NotNil(t, incoming.Spec.Upstream.Auth.PolicyParams) + assert.Equal(t, storedCred, (*incoming.Spec.Upstream.Auth.PolicyParams)["clientSecret"]) + }) + + t.Run("supplied policyParams wins so rotation still works", func(t *testing.T) { + incoming := storedOAuth2Provider() + rotated := map[string]interface{}{"clientSecret": newCred} + incoming.Spec.Upstream.Auth.PolicyParams = &rotated + + inheritLLMProviderCredential(&incoming, storedOAuth2Provider()) + + assert.Equal(t, newCred, (*incoming.Spec.Upstream.Auth.PolicyParams)["clientSecret"], + "must not clobber rotated policyParams") + }) + + // Regression: a client that always serializes policyParams as `{}` must still inherit. + t.Run("empty-but-present policyParams inherits the stored policyParams", func(t *testing.T) { + incoming := storedOAuth2Provider() + empty := map[string]interface{}{} + incoming.Spec.Upstream.Auth.PolicyParams = &empty + + inheritLLMProviderCredential(&incoming, storedOAuth2Provider()) + + require.NotNil(t, incoming.Spec.Upstream.Auth.PolicyParams) + assert.Equal(t, storedCred, (*incoming.Spec.Upstream.Auth.PolicyParams)["clientSecret"], + "an empty policyParams map must not be treated as a supplied credential") + }) + + // Rotating one policyParams key must not drop others untouched. + t.Run("partial policyParams preserves an omitted stored key", func(t *testing.T) { + incoming := storedOAuth2Provider() + partial := map[string]interface{}{"tokenEndpoint": "https://idp.example.com/token-v2"} + incoming.Spec.Upstream.Auth.PolicyParams = &partial + + inheritLLMProviderCredential(&incoming, storedOAuth2Provider()) + + require.NotNil(t, incoming.Spec.Upstream.Auth.PolicyParams) + assert.Equal(t, "https://idp.example.com/token-v2", (*incoming.Spec.Upstream.Auth.PolicyParams)["tokenEndpoint"], + "the explicitly-supplied key must win") + assert.Equal(t, storedCred, (*incoming.Spec.Upstream.Auth.PolicyParams)["clientSecret"], + "an untouched stored key must be preserved, not dropped") + }) } func TestInheritLLMProxyCredentials(t *testing.T) { @@ -272,15 +353,40 @@ func TestInheritLLMProxyCredentials(t *testing.T) { assert.Nil(t, incoming.Spec.Provider.Auth.Value) }) + + // Regression: see the equivalent oauth2 case in TestInheritLLMProviderCredential. + t.Run("oauth2 primary provider auth omitted entirely inherits the stored policyParams", func(t *testing.T) { + params := map[string]interface{}{"tokenEndpoint": "https://idp.example.com/token", "clientSecret": storedCred} + storedOAuth2 := func() api.LLMProxyConfiguration { + var cfg api.LLMProxyConfiguration + cfg.Spec.Provider = api.LLMProxyProvider{ + Id: "openai-provider", + Auth: &api.LLMUpstreamAuth{Type: "oauth2", PolicyParams: ¶ms}, + } + return cfg + } + + var incoming api.LLMProxyConfiguration + incoming.Spec.Provider = api.LLMProxyProvider{Id: "openai-provider"} + + inheritLLMProxyCredentials(&incoming, storedOAuth2()) + + require.NotNil(t, incoming.Spec.Provider.Auth) + require.NotNil(t, incoming.Spec.Provider.Auth.PolicyParams) + assert.Equal(t, storedCred, (*incoming.Spec.Provider.Auth.PolicyParams)["clientSecret"]) + }) } func TestInheritMCPProxyCredential(t *testing.T) { stored := func() api.MCPProxyConfiguration { var cfg api.MCPProxyConfiguration cfg.Spec.Upstream.Auth = &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{Header: sp("Authorization"), Type: "api-key", Value: sp(storedCred)} return cfg } @@ -314,6 +420,30 @@ func TestInheritMCPProxyCredential(t *testing.T) { inheritMCPProxyCredential(&incoming, stored()) assert.Nil(t, incoming.Spec.Upstream.Auth.Value) }) + + // Regression: see the equivalent oauth2 case in TestInheritLLMProviderCredential. + t.Run("oauth2 auth omitted entirely inherits the stored policyParams", func(t *testing.T) { + storedOAuth2 := func() api.MCPProxyConfiguration { + var cfg api.MCPProxyConfiguration + params := map[string]interface{}{"tokenEndpoint": "https://idp.example.com/token", "clientSecret": storedCred} + cfg.Spec.Upstream.Auth = &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{Type: "oauth2", PolicyParams: ¶ms} + return cfg + } + + var incoming api.MCPProxyConfiguration + inheritMCPProxyCredential(&incoming, storedOAuth2()) + + require.NotNil(t, incoming.Spec.Upstream.Auth) + require.NotNil(t, incoming.Spec.Upstream.Auth.PolicyParams) + assert.Equal(t, storedCred, (*incoming.Spec.Upstream.Auth.PolicyParams)["clientSecret"]) + }) } // errOnGetConfigDB wraps the shared test double to simulate a lookup failure @@ -376,3 +506,37 @@ func TestStoredSourceForUpdate(t *testing.T) { assert.Contains(t, err.Error(), "credential inheritance") }) } + +// Rotating an api-key provider from policyParams to the legacy Value field +// must not resurrect the stale stored policyParams alongside it. +func TestInheritLLMProviderCredential_SwitchingMechanismDoesNotResurrectTheOther(t *testing.T) { + storedViaPolicyParams := func() api.LLMProviderConfiguration { + var cfg api.LLMProviderConfiguration + cfg.Spec.Upstream.Url = sp("https://api.openai.com/v1") + params := map[string]interface{}{ + "request": map[string]interface{}{ + "headers": []interface{}{map[string]interface{}{"name": "Authorization", "value": "Bearer stored-via-params"}}, + }, + } + cfg.Spec.Upstream.Auth = &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{Type: "api-key", PolicyParams: ¶ms} + return cfg + } + + incoming := storedViaPolicyParams() + incoming.Spec.Upstream.Auth.PolicyParams = nil + incoming.Spec.Upstream.Auth.Header = sp("Authorization") + incoming.Spec.Upstream.Auth.Value = sp("rotated-via-value") + + inheritLLMProviderCredential(&incoming, storedViaPolicyParams()) + + assert.Equal(t, "rotated-via-value", *incoming.Spec.Upstream.Auth.Value) + assert.Nil(t, incoming.Spec.Upstream.Auth.PolicyParams, + "switching to Value must not resurrect the stored policyParams from the old mechanism") +} diff --git a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go index 4307f05adc..431e77ca1c 100644 --- a/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_provider_transformer_test.go @@ -38,6 +38,30 @@ func stringPtr(s string) *string { return &s } +// apiKeyUpstreamAuth builds an api-key-type upstream auth fixture. Its anonymous +// struct type must match LLMProviderConfigData_Upstream.Auth's field order/tags exactly. +func apiKeyUpstreamAuth(header, value string) *struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` +} { + return &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, + Header: stringPtr(header), + Value: stringPtr(value), + } +} + // loadDummyConfig creates a dummy router configuration func loadDummyConfig() config.RouterConfig { return config.RouterConfig{ @@ -189,15 +213,7 @@ func TestTransform_FullProvider(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.openai.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer sk-test123"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer sk-test123"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -476,15 +492,7 @@ func TestTransform_ApiKeyAuth(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("X-API-Key"), - Value: stringPtr("secret-key-123"), - }, + Auth: apiKeyUpstreamAuth("X-API-Key", "secret-key-123"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -523,15 +531,23 @@ func TestTransform_ApiKeyAuth(t *testing.T) { } } -// TestTransform_OtherAndNoneAuth verifies that "other" and "none" upstream auth -// types transform successfully but attach no upstream auth policy - for "other" -// authentication is handled by user-attached policies, for "none" there is none. +// TestTransform_OtherAndNoneAuth verifies "other" and "none" upstream auth +// transform successfully but attach no *built-in* auth policy. func TestTransform_OtherAndNoneAuth(t *testing.T) { - for _, authType := range []api.LLMProviderConfigDataUpstreamAuthType{ - api.LLMProviderConfigDataUpstreamAuthTypeOther, - api.LLMProviderConfigDataUpstreamAuthTypeNone, - } { - t.Run(string(authType), func(t *testing.T) { + tests := []struct { + authType api.LLMProviderConfigDataUpstreamAuthType + policyName *string + policyParams *map[string]interface{} + }{ + { + authType: api.LLMProviderConfigDataUpstreamAuthTypeOther, + policyName: stringPtr(testCustomAuthPolicyName), + policyParams: &map[string]interface{}{"foo": "bar"}, + }, + {authType: api.LLMProviderConfigDataUpstreamAuthTypeNone}, + } + for _, tc := range tests { + t.Run(string(tc.authType), func(t *testing.T) { transformer, _ := setupTestTransformer(t) provider := &api.LLMProviderConfiguration{ @@ -545,11 +561,16 @@ func TestTransform_OtherAndNoneAuth(t *testing.T) { Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ - Type: authType, + Type: tc.authType, + PolicyName: tc.policyName, + PolicyParams: tc.policyParams, }, }, AccessControl: api.LLMAccessControl{ @@ -562,14 +583,15 @@ func TestTransform_OtherAndNoneAuth(t *testing.T) { result, err := transformer.Transform(provider, output) require.NoError(t, err) - // No upstream auth policy should be attached to any operation. + // No built-in upstream auth (api-key/set-headers) policy should be + // attached to any operation. for _, op := range result.Spec.Operations { if op.Policies == nil { continue } for _, pol := range *op.Policies { assert.NotEqual(t, constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, pol.Name, - "auth type %q should not attach an upstream auth policy", authType) + "auth type %q should not attach the built-in upstream auth policy", tc.authType) } } }) @@ -590,9 +612,12 @@ func TestTransform_UnsupportedAuthType(t *testing.T) { Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: "bearer", // Unsupported type Header: stringPtr("Authorization"), @@ -1714,15 +1739,7 @@ func TestTransform_AuthWithAllowAll(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer sk-test"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer sk-test"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -2258,15 +2275,7 @@ func TestTransform_UpstreamAuth_Plus_APILevelPolicy_AllowAll(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer sk-test"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer sk-test"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -2347,15 +2356,7 @@ func TestTransform_UpstreamAuth_Plus_APILevelPolicy_DenyAll(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("X-API-Key"), - Value: stringPtr("secret123"), - }, + Auth: apiKeyUpstreamAuth("X-API-Key", "secret123"), }, AccessControl: api.LLMAccessControl{ Mode: api.DenyAll, @@ -3505,15 +3506,7 @@ func TestTransform_Auth_Plus_APILevel_Plus_OperationLevel_AllowAll(t *testing.T) Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer secret-token"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer secret-token"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -3635,15 +3628,7 @@ func TestTransform_Auth_Plus_APILevel_Plus_OperationLevel_DenyAll(t *testing.T) Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("X-API-Key"), - Value: stringPtr("secret-key"), - }, + Auth: apiKeyUpstreamAuth("X-API-Key", "secret-key"), }, AccessControl: api.LLMAccessControl{ Mode: api.DenyAll, @@ -3972,15 +3957,7 @@ func TestTransform_AllPolicyTypes_WildcardExceptions_WildcardOperations_AllowAll Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer token"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer token"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -4202,15 +4179,7 @@ func TestTransform_AllPolicyTypes_WildcardExceptions_WildcardOperations_DenyAll( Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.example.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("X-API-Key"), - Value: stringPtr("secret"), - }, + Auth: apiKeyUpstreamAuth("X-API-Key", "secret"), }, AccessControl: api.LLMAccessControl{ Mode: api.DenyAll, @@ -5424,15 +5393,7 @@ func TestTransform_ComplexCombined_MaximumComplexity_AllowAll(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.openai.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer sk-test123"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer sk-test123"), }, AccessControl: api.LLMAccessControl{ Mode: api.AllowAll, @@ -5733,15 +5694,7 @@ func TestTransform_ComplexCombined_MaximumComplexity_DenyAll(t *testing.T) { Template: "openai", Upstream: api.LLMProviderConfigData_Upstream{ Url: stringPtr("https://api.openai.com"), - Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` - }{ - Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, - Header: stringPtr("Authorization"), - Value: stringPtr("Bearer sk-test123"), - }, + Auth: apiKeyUpstreamAuth("Authorization", "Bearer sk-test123"), }, AccessControl: api.LLMAccessControl{ Mode: api.DenyAll, diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer.go b/gateway/gateway-controller/pkg/utils/llm_transformer.go index 9f0178bf5e..bb64443d51 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer.go @@ -94,6 +94,23 @@ func (t *LLMProviderTransformer) resolvePolicyVersion(name string) (string, erro return t.policyVersionResolver.Resolve(name) } +// resolvePolicyVersionOverride errors if an optional caller-requested override +// doesn't match the one version of name actually loaded in this gateway image. +func (t *LLMProviderTransformer) resolvePolicyVersionOverride(name string, override *string) (string, error) { + resolved, err := t.resolvePolicyVersion(name) + if err != nil { + return "", err + } + if override == nil { + return resolved, nil + } + trimmed := strings.TrimSpace(*override) + if trimmed == "" || trimmed == resolved { + return resolved, nil + } + return "", fmt.Errorf("policy '%s' version '%s' was requested, but this gateway build only has '%s' loaded", name, trimmed, resolved) +} + func (t *LLMProviderTransformer) getTemplateByHandle(handle string) (*models.StoredLLMProviderTemplate, error) { return t.db.GetLLMProviderTemplateByHandle(handle) } @@ -458,28 +475,51 @@ func (t *LLMProviderTransformer) transformProvider(provider *api.LLMProviderConf upstream := provider.Spec.Upstream var upstreamAuthPolicy *api.Policy if upstream.Auth != nil { - switch upstream.Auth.Type { + auth := upstream.Auth + switch auth.Type { case api.LLMProviderConfigDataUpstreamAuthTypeApiKey: - // Add API Key auth policy at API level - params, err := GetUpstreamAuthApikeyPolicyParams(*upstream.Auth.Header, *upstream.Auth.Value) + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, + func() (map[string]interface{}, error) { + if auth.Header == nil || *auth.Header == "" { + return nil, fmt.Errorf("upstream.auth.header is required") + } + if auth.Value == nil || *auth.Value == "" { + return nil, fmt.Errorf("upstream.auth.value is required") + } + return GetUpstreamAuthApikeyPolicyParams(*auth.Header, *auth.Value) + }, + t.resolvePolicyVersionOverride, + ) + if err != nil { + return nil, err + } + upstreamAuthPolicy = pol + case api.LLMProviderConfigDataUpstreamAuthTypeOauth2: + // No typed-field fallback for oauth2 - policyParams is always required. + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME, nil, t.resolvePolicyVersionOverride) if err != nil { - return nil, fmt.Errorf("failed to build upstream auth params: %w", err) + return nil, err } - policyVersion, err := t.resolvePolicyVersion(constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME) + upstreamAuthPolicy = pol + case api.LLMProviderConfigDataUpstreamAuthTypeOther: + // No default policy name (policyName is required) and no typed-field + // fallback (policyParams is always required). + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + "", nil, t.resolvePolicyVersionOverride) if err != nil { return nil, err } - mh := api.Policy{ - Name: constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, - Version: policyVersion, Params: ¶ms} - upstreamAuthPolicy = &mh - case api.LLMProviderConfigDataUpstreamAuthTypeOther, - api.LLMProviderConfigDataUpstreamAuthTypeNone: - // "other": auth handled entirely by user-attached policies. - // "none": no upstream authentication. In both cases the gateway - // attaches no auth policy of its own. + upstreamAuthPolicy = pol + case api.LLMProviderConfigDataUpstreamAuthTypeNone: + // No auth policy attached; auth (if any) is handled by user-attached + // policies elsewhere in the chain. default: - return nil, fmt.Errorf("unsupported upstream auth type: %s", upstream.Auth.Type) + return nil, fmt.Errorf("unsupported upstream auth type: %s", auth.Type) } } @@ -821,41 +861,49 @@ func apiKeyAuthValuePrefix(globalPolicies *[]api.Policy) string { return "" } +// proxyUpstreamAuthPolicy builds the api.Policy for an LlmProxy +// provider/additionalProviders auth config. valuePrefix is the provider's own +// api-key-auth value prefix, applied the same way to the loopback credential. func (t *LLMProviderTransformer) proxyUpstreamAuthPolicy(auth *api.LLMUpstreamAuth, valuePrefix, field string) (*api.Policy, error) { if auth == nil { return nil, nil } switch auth.Type { case api.LLMUpstreamAuthTypeApiKey: - if auth.Header == nil || *auth.Header == "" { - return nil, fmt.Errorf("%s.header is required", field) - } - if auth.Value == nil || *auth.Value == "" { - return nil, fmt.Errorf("%s.value is required", field) - } - // The loopback hop re-enters the provider's own api-key-auth. When that policy - // declares a valuePrefix (e.g. "Bearer"), the injected credential must be prefixed - // the same way — a single space separator matches how the provider strips it. - value := *auth.Value - if valuePrefix != "" { - value = valuePrefix + " " + value - } - params, err := GetUpstreamAuthApikeyPolicyParams(*auth.Header, value) - if err != nil { - return nil, fmt.Errorf("failed to build upstream auth params: %w", err) - } - policyVersion, err := t.resolvePolicyVersion(constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME) - if err != nil { - return nil, err - } - return &api.Policy{ - Name: constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, - Version: policyVersion, - Params: ¶ms, - }, nil - case api.LLMUpstreamAuthTypeOther, api.LLMUpstreamAuthTypeNone: - // "other": auth handled entirely by user-attached policies. - // "none": no upstream authentication. No auth policy is attached. + return buildUpstreamAuthPolicy(string(auth.Type), field, + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, + func() (map[string]interface{}, error) { + if auth.Header == nil || *auth.Header == "" { + return nil, fmt.Errorf("%s.header is required", field) + } + if auth.Value == nil || *auth.Value == "" { + return nil, fmt.Errorf("%s.value is required", field) + } + // Loopback re-enters the provider's own api-key-auth, so match + // its valuePrefix stripping. + value := *auth.Value + if valuePrefix != "" { + value = valuePrefix + " " + value + } + return GetUpstreamAuthApikeyPolicyParams(*auth.Header, value) + }, + t.resolvePolicyVersionOverride, + ) + case api.LLMUpstreamAuthTypeOauth2: + // No typed-field fallback for oauth2 - policyParams is always required. + return buildUpstreamAuthPolicy(string(auth.Type), field, + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME, nil, t.resolvePolicyVersionOverride) + case api.LLMUpstreamAuthTypeOther: + // No default policy name (policyName is required) and no typed-field + // fallback (policyParams is always required). + return buildUpstreamAuthPolicy(string(auth.Type), field, + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + "", nil, t.resolvePolicyVersionOverride) + case api.LLMUpstreamAuthTypeNone: + // No auth policy attached; auth (if any) is handled by user-attached + // policies elsewhere. return nil, nil default: return nil, fmt.Errorf("unsupported upstream auth type: %s", auth.Type) diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer_multiprovider_test.go b/gateway/gateway-controller/pkg/utils/llm_transformer_multiprovider_test.go index 722c551435..e880f2b306 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer_multiprovider_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer_multiprovider_test.go @@ -325,97 +325,136 @@ func TestLLMProviderTransformer_TransformProxy_RejectsInvalidAdditionalProviderS assert.Equal(t, "additional provider 'invalid-provider' source configuration is not LLMProviderConfiguration", err.Error()) } -// Test that a proxy that loops back into a provider with a downstream api-key-auth policy -func TestLLMProviderTransformer_TransformProxy_LoopbackAuthCarriesProviderValuePrefix(t *testing.T) { +// TestLLMProviderTransformer_TransformProxy_AdditionalProviderOAuth2AuthIsIsolated +// covers a proxy's primary provider and an additionalProviders entry, each with +// independent oauth2 credentials, emitting two separate oauth2 Policy +// attachments rather than one shared one. +func TestLLMProviderTransformer_TransformProxy_AdditionalProviderOAuth2AuthIsIsolated(t *testing.T) { store := storage.NewConfigStore() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db := newTestSQLiteStorage(t, logger) template := &models.StoredLLMProviderTemplate{ - UUID: "0000-db-template-id-0000-000000000003", + UUID: "0000-db-template-id-0000-000000000004", Configuration: api.LLMProviderTemplate{ ApiVersion: api.LLMProviderTemplateApiVersionGatewayApiPlatformWso2Comv1, Kind: api.LLMProviderTemplateKindLlmProviderTemplate, - Metadata: api.Metadata{Name: "mistralai"}, - Spec: api.LLMProviderTemplateData{DisplayName: "mistralai"}, + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{DisplayName: "openai"}, }, } require.NoError(t, db.SaveLLMProviderTemplate(template)) - // Provider whose downstream api-key-auth requires a "Bearer" prefix, carried as a - // global policy exactly as platform-api deploys it. - providerSourceConfig := api.LLMProviderConfiguration{ - ApiVersion: api.LLMProviderConfigurationApiVersionGatewayApiPlatformWso2Comv1, - Kind: api.LLMProviderConfigurationKindLlmProvider, - Metadata: api.Metadata{Name: "mistral-provider"}, - Spec: api.LLMProviderConfigData{ - DisplayName: "mistral-provider", - Version: "v1.0", - Context: stringPtr("/mistral-provider"), - Template: "mistralai", - Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://example.com")}, - AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, - GlobalPolicies: &[]api.Policy{{ - Name: constants.API_KEY_AUTH_POLICY_NAME, - Params: &map[string]interface{}{ - "in": "header", - "key": "X-API-Key", - "valuePrefix": "Bearer", - }, - }}, - }, + saveProvider := func(name, context string) { + providerSourceConfig := api.LLMProviderConfiguration{ + ApiVersion: api.LLMProviderConfigurationApiVersionGatewayApiPlatformWso2Comv1, + Kind: api.LLMProviderConfigurationKindLlmProvider, + Metadata: api.Metadata{Name: name}, + Spec: api.LLMProviderConfigData{ + DisplayName: name, + Version: "v1.0", + Context: stringPtr(context), + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: stringPtr("https://example.com")}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + require.NoError(t, db.SaveConfig(&models.StoredConfig{ + UUID: name + "-uuid", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: name, + DisplayName: name, + Version: "v1.0", + SourceConfiguration: providerSourceConfig, + DesiredState: models.StateDeployed, + })) } - require.NoError(t, db.SaveConfig(&models.StoredConfig{ - UUID: "mistral-provider-uuid", - Kind: string(api.LLMProviderConfigurationKindLlmProvider), - Handle: "mistral-provider", - DisplayName: "mistral-provider", - Version: "v1.0", - SourceConfiguration: providerSourceConfig, - DesiredState: models.StateDeployed, - })) + saveProvider("provider-a", "/provider-a") + saveProvider("provider-b", "/provider-b") transformer := NewLLMProviderTransformer(store, db, &config.RouterConfig{ListenerPort: 8080}, newTestPolicyVersionResolver()) + // provider-b differs from provider-a in clientId, tokenEndpoint AND + // clientSecret, not just name, to lock in isolation on every field the + // cache key discriminates by. proxy := &api.LLMProxyConfiguration{ ApiVersion: api.LLMProxyConfigurationApiVersionGatewayApiPlatformWso2Comv1, Kind: api.LLMProxyConfigurationKindLlmProxy, - Metadata: api.Metadata{Name: "proxy-from-mistral"}, + Metadata: api.Metadata{Name: "oauth2-multi"}, Spec: api.LLMProxyConfigData{ - DisplayName: "proxy-from-mistral", + DisplayName: "oauth2-multi", Version: "v1.0", Provider: api.LLMProxyProvider{ - Id: "mistral-provider", + Id: "provider-a", Auth: &api.LLMUpstreamAuth{ - Type: api.LLMUpstreamAuthTypeApiKey, - Header: stringPtr("X-API-Key"), - Value: stringPtr(`{{ secret "sec-1" }}`), + Type: api.LLMUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": "https://idp-a.example.com/token", + "clientId": "client-a", + "clientSecret": "secret-a", + }, }, }, + AdditionalProviders: &[]api.LLMProxyAdditionalProvider{{ + Id: "provider-b", + Auth: &api.LLMUpstreamAuth{ + Type: api.LLMUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": "https://idp-b.example.com/token", + "clientId": "client-b", + "clientSecret": "secret-b", + }, + }, + }}, }, } result, err := transformer.Transform(proxy, &api.RestAPI{}) require.NoError(t, err) - var authPolicy *api.Policy + // No operationPolicies attached, so the transformer only generates + // wildcard catch-all routes - any POST operation carries both oauth2 + // attachments. + var postOp *api.Operation for i := range result.Spec.Operations { - if result.Spec.Operations[i].Policies == nil { - continue - } - for _, pol := range *result.Spec.Operations[i].Policies { - if pol.Name == constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME { - p := pol - authPolicy = &p - break - } - } - if authPolicy != nil { + if result.Spec.Operations[i].Method != nil && *result.Spec.Operations[i].Method == api.OperationMethod("POST") { + postOp = &result.Spec.Operations[i] break } } - require.NotNil(t, authPolicy, "expected an upstream auth (set-headers) policy on the proxy") - assert.Equal(t, `Bearer {{ secret "sec-1" }}`, firstRequestHeaderValue(t, authPolicy.Params)) + require.NotNil(t, postOp) + require.NotNil(t, postOp.Policies) + + var oauth2Policies []api.Policy + for _, pol := range *postOp.Policies { + if pol.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + oauth2Policies = append(oauth2Policies, pol) + } + } + // Two separate oauth2 attachments on the same operation - the shape that + // collided under the old API-identity-keyed cache. + require.Len(t, oauth2Policies, 2) + require.NotNil(t, oauth2Policies[0].ExecutionCondition) + require.NotNil(t, oauth2Policies[1].ExecutionCondition) + assert.Contains(t, *oauth2Policies[0].ExecutionCondition, "provider-a") + assert.Contains(t, *oauth2Policies[1].ExecutionCondition, "provider-b") + + require.NotNil(t, oauth2Policies[0].Params) + require.NotNil(t, oauth2Policies[1].Params) + paramsA := *oauth2Policies[0].Params + paramsB := *oauth2Policies[1].Params + + // Every field oauth2ConfigDiscriminator keys on must actually differ, or + // the two would collide on the same Redis key regardless. + assert.NotEqual(t, paramsA["clientId"], paramsB["clientId"]) + assert.NotEqual(t, paramsA["tokenEndpoint"], paramsB["tokenEndpoint"]) + assert.NotEqual(t, paramsA["clientSecret"], paramsB["clientSecret"]) + assert.Equal(t, "client-a", paramsA["clientId"]) + assert.Equal(t, "client-b", paramsB["clientId"]) + assert.Equal(t, "https://idp-a.example.com/token", paramsA["tokenEndpoint"]) + assert.Equal(t, "https://idp-b.example.com/token", paramsB["tokenEndpoint"]) + assert.Equal(t, "secret-a", paramsA["clientSecret"]) + assert.Equal(t, "secret-b", paramsB["clientSecret"]) } func firstRequestHeaderValue(t *testing.T, params *map[string]interface{}) string { diff --git a/gateway/gateway-controller/pkg/utils/llm_transformer_test.go b/gateway/gateway-controller/pkg/utils/llm_transformer_test.go index 94e3e55bdf..620576f16e 100644 --- a/gateway/gateway-controller/pkg/utils/llm_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/llm_transformer_test.go @@ -2019,9 +2019,12 @@ func TestTransformProvider_WithUpstreamAuth(t *testing.T) { Upstream: api.LLMProviderConfigData_Upstream{ Url: &upstreamURL, Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, Header: &authHeader, @@ -2054,6 +2057,426 @@ func TestTransformProvider_WithUpstreamAuth(t *testing.T) { } } +// TestTransformProvider_ApiKeyWithPolicyParams covers type: api-key configured +// via policyParams instead of the deprecated header/value fields. +func TestTransformProvider_ApiKeyWithPolicyParams(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + err := store.AddTemplate(template) + require.NoError(t, err) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-apikey-policyparams"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider (api-key via policyParams)", + Version: "1.0.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeApiKey, + // set-headers' own native param shape - policyParams for api-key is + // forwarded verbatim, with no header/value defaulting at all. + PolicyParams: &map[string]interface{}{ + "request": map[string]interface{}{ + "headers": []interface{}{ + map[string]interface{}{"name": "X-Api-Key", "value": "sk-from-policyparams"}, + }, + }, + }, + }, + }, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(provider, output) + assert.NoError(t, err) + assert.NotNil(t, result) + + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + var found *api.Policy + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME { + found = &p + break + } + } + require.NotNil(t, found, "operation %s %s should include the set-headers policy", op.EffectiveMethod(), op.EffectivePath()) + require.NotNil(t, found.Params) + // The configured policyParams must reach set-headers unchanged - not the + // GetUpstreamAuthApikeyPolicyParams-rendered shape header/value would produce. + request, ok := (*found.Params)["request"].(map[string]interface{}) + require.True(t, ok, "expected policyParams.request to survive verbatim, got %+v", *found.Params) + headers, ok := request["headers"].([]interface{}) + require.True(t, ok) + require.Len(t, headers, 1) + entry := headers[0].(map[string]interface{}) + assert.Equal(t, "X-Api-Key", entry["name"]) + assert.Equal(t, "sk-from-policyparams", entry["value"]) + } +} + +func TestTransformProvider_WithOAuth2UpstreamAuth(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ + ListenerPort: 8080, + } + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + err := store.AddTemplate(template) + require.NoError(t, err) + + upstreamURL := "https://api.openai.com" + tokenEndpoint := "https://idp.example.com/oauth2/token" + clientID := "gateway-client" + clientSecret := "s3cr3t" + purgeStatusCodes := []int{401, 403} + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-oauth2"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider (OAuth2)", + Version: "1.0.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": tokenEndpoint, + "clientId": clientID, + "clientSecret": clientSecret, + "tokenPurgeStatusCodes": purgeStatusCodes, + }, + }, + }, + AccessControl: api.LLMAccessControl{ + Mode: api.AllowAll, + }, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(provider, output) + assert.NoError(t, err) + assert.NotNil(t, result) + + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + found := false + var foundParams *map[string]interface{} + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + found = true + foundParams = p.Params + break + } + } + assert.True(t, found, "operation %s %s should include the oauth2 policy", op.EffectiveMethod(), op.EffectivePath()) + require.NotNil(t, foundParams) + // policyParams is forwarded verbatim - grantType/clientAuthMethod + // defaulting is oauth2-generator's own responsibility now. + assert.Equal(t, tokenEndpoint, (*foundParams)["tokenEndpoint"]) + assert.Equal(t, clientID, (*foundParams)["clientId"]) + assert.Equal(t, clientSecret, (*foundParams)["clientSecret"]) + assert.Equal(t, purgeStatusCodes, (*foundParams)["tokenPurgeStatusCodes"], "policyParams should reach the policy unchanged") + } +} + +// TestTransformProvider_PolicyVersionOverride covers a matching policyVersion +// override succeeding and a mismatched one failing loudly. +func TestTransformProvider_PolicyVersionOverride(t *testing.T) { + newProvider := func(policyVersion *string) *api.LLMProviderConfiguration { + upstreamURL := "https://api.openai.com" + return &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-pinned"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider (pinned policyVersion)", + Version: "1.0.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{"tokenEndpoint": "https://idp.example.com/oauth2/token"}, + PolicyVersion: policyVersion, + }, + }, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + } + + newTransformer := func(t *testing.T) *LLMProviderTransformer { + store := storage.NewConfigStore() + db := newTestMockDB() + transformer := NewLLMProviderTransformer(store, db, &config.RouterConfig{ListenerPort: 8080}, newTestPolicyVersionResolver()) + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{Metadata: api.Metadata{Name: "openai"}, Spec: api.LLMProviderTemplateData{}}, + } + db.SaveLLMProviderTemplate(template) + require.NoError(t, store.AddTemplate(template)) + return transformer + } + + t.Run("matching pin succeeds", func(t *testing.T) { + transformer := newTransformer(t) + result, err := transformer.Transform(newProvider(stringPtr("v9.9.7")), &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + assert.Equal(t, "v9.9.7", p.Version) + } + } + } + }) + + t.Run("mismatched pin fails loudly instead of silently using the loaded version", func(t *testing.T) { + transformer := newTransformer(t) + _, err := transformer.Transform(newProvider(stringPtr("v1")), &api.RestAPI{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "v1") + assert.Contains(t, err.Error(), "v9.9.7") + }) +} + +// TestTransformProvider_OAuth2PolicyNameOverride covers a caller-supplied +// policyName overriding the built-in oauth2-generator default. +func TestTransformProvider_OAuth2PolicyNameOverride(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + transformer := NewLLMProviderTransformer(store, db, &config.RouterConfig{ListenerPort: 8080}, newTestPolicyVersionResolver()) + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{Metadata: api.Metadata{Name: "openai"}, Spec: api.LLMProviderTemplateData{}}, + } + db.SaveLLMProviderTemplate(template) + require.NoError(t, store.AddTemplate(template)) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-oauth2-fork"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider (oauth2, forked policy)", + Version: "1.0.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyName: stringPtr(testCustomAuthPolicyName), + PolicyParams: &map[string]interface{}{"tokenEndpoint": "https://idp.example.com/oauth2/token"}, + }, + }, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + result, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + found := false + for _, p := range *op.Policies { + if p.Name == testCustomAuthPolicyName { + found = true + assert.Equal(t, testCustomAuthPolicyVersion, p.Version) + } + assert.NotEqual(t, constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME, p.Name, + "the built-in oauth2-generator policy must not also be attached alongside the override") + } + assert.True(t, found, "operation %s %s should include the overridden policy", op.EffectiveMethod(), op.EffectivePath()) + } +} + +func TestTransformProvider_WithOAuth2PasswordGrant(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + err := store.AddTemplate(template) + require.NoError(t, err) + + upstreamURL := "https://api.openai.com" + tokenEndpoint := "https://legacy-idp.example.com/oauth2/token" + clientID := "gateway-client" + clientSecret := "s3cr3t" + username := "resource-owner" + password := "hunter2" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-oauth2-password"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider (OAuth2 password grant)", + Version: "1.0.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "grantType": "password", + "tokenEndpoint": tokenEndpoint, + "clientId": clientID, + "clientSecret": clientSecret, + "username": username, + "password": password, + }, + }, + }, + AccessControl: api.LLMAccessControl{ + Mode: api.AllowAll, + }, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(provider, output) + assert.NoError(t, err) + assert.NotNil(t, result) + + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + var foundParams *map[string]interface{} + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + foundParams = p.Params + break + } + } + require.NotNil(t, foundParams) + assert.Equal(t, "password", (*foundParams)["grantType"]) + assert.Equal(t, username, (*foundParams)["username"]) + assert.Equal(t, password, (*foundParams)["password"]) + } +} + +// TestTransformProvider_WithOAuth2UpstreamAuth_MissingPolicyParams covers the +// only CRD-level requirement for type: oauth2 - policyParams must be present; +// its contents are validated by oauth2-generator itself, not here. +func TestTransformProvider_WithOAuth2UpstreamAuth_MissingPolicyParams(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + err := store.AddTemplate(template) + require.NoError(t, err) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-oauth2-invalid"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider (OAuth2, invalid)", + Version: "1.0.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.LLMProviderConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.LLMProviderConfigDataUpstreamAuthTypeOauth2, + // PolicyParams deliberately omitted + }, + }, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(provider, output) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "policyParams") +} + func TestTransformProxy_WithUpstreamAuth(t *testing.T) { store := storage.NewConfigStore() db := newTestMockDB() @@ -2148,14 +2571,23 @@ func TestTransformProxy_WithUpstreamAuth(t *testing.T) { } // TestTransformProxy_OtherAndNoneUpstreamAuth verifies that a proxy-level -// upstream auth override of "other" or "none" transforms successfully and -// attaches no upstream auth policy. +// upstream auth override of "other" (with a named policy) or "none" +// transforms successfully and attaches no *built-in* upstream auth policy. func TestTransformProxy_OtherAndNoneUpstreamAuth(t *testing.T) { - for _, authType := range []api.LLMUpstreamAuthType{ - api.LLMUpstreamAuthTypeOther, - api.LLMUpstreamAuthTypeNone, - } { - t.Run(string(authType), func(t *testing.T) { + tests := []struct { + authType api.LLMUpstreamAuthType + policyName *string + policyParams *map[string]interface{} + }{ + { + authType: api.LLMUpstreamAuthTypeOther, + policyName: stringPtr(testCustomAuthPolicyName), + policyParams: &map[string]interface{}{"foo": "bar"}, + }, + {authType: api.LLMUpstreamAuthTypeNone}, + } + for _, tc := range tests { + t.Run(string(tc.authType), func(t *testing.T) { store := storage.NewConfigStore() db := newTestMockDB() routerConfig := &config.RouterConfig{ListenerPort: 8080} @@ -2205,8 +2637,12 @@ func TestTransformProxy_OtherAndNoneUpstreamAuth(t *testing.T) { DisplayName: "OpenAI Proxy", Version: "v1.0", Provider: api.LLMProxyProvider{ - Id: "openai-provider", - Auth: &api.LLMUpstreamAuth{Type: authType}, + Id: "openai-provider", + Auth: &api.LLMUpstreamAuth{ + Type: tc.authType, + PolicyName: tc.policyName, + PolicyParams: tc.policyParams, + }, }, }, } @@ -2227,13 +2663,310 @@ func TestTransformProxy_OtherAndNoneUpstreamAuth(t *testing.T) { continue } assert.NotEqual(t, constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME, p.Name, - "proxy auth type %q should not attach an upstream auth policy", authType) + "proxy auth type %q should not attach the built-in upstream auth policy", tc.authType) } } }) } } +func TestTransformProxy_WithOAuth2UpstreamAuth(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + err := store.AddTemplate(template) + require.NoError(t, err) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-oauth2-proxy"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + }, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + providerOut := &api.RestAPI{} + providerAPI, err := transformer.Transform(provider, providerOut) + require.NoError(t, err) + require.NotNil(t, providerAPI) + + storedProvider := &models.StoredConfig{ + UUID: "0000-prov-cfg-2-0000-000000000000", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: "openai-provider-oauth2-proxy", + DisplayName: "OpenAI Provider", + Version: "v1.0", + Configuration: *providerAPI, + SourceConfiguration: *provider, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + db.SaveConfig(storedProvider) + err = store.Add(storedProvider) + require.NoError(t, err) + + tokenEndpoint := "https://idp.example.com/oauth2/token" + clientID := "proxy-client" + clientSecret := "proxy-secret" + purgeStatusCodes := []int{401, 403} + proxy := &api.LLMProxyConfiguration{ + Metadata: api.Metadata{Name: "openai-proxy-oauth2"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "OpenAI Proxy (OAuth2)", + Version: "v1.0", + Provider: api.LLMProxyProvider{ + Id: "openai-provider-oauth2-proxy", + Auth: &api.LLMUpstreamAuth{ + Type: api.LLMUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": tokenEndpoint, + "clientId": clientID, + "clientSecret": clientSecret, + "tokenPurgeStatusCodes": purgeStatusCodes, + }, + }, + }, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(proxy, output) + require.NoError(t, err) + require.NotNil(t, result) + + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + found := false + var foundParams *map[string]interface{} + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + found = true + foundParams = p.Params + break + } + } + assert.True(t, found, "operation %s %s should include the oauth2 policy", op.EffectiveMethod(), op.EffectivePath()) + require.NotNil(t, foundParams) + assert.Equal(t, []int{401, 403}, (*foundParams)["tokenPurgeStatusCodes"], "oauth2TokenPurgeStatusCodes should reach the policy params unchanged via the LlmProxy path too") + } +} + +// TestTransformProxy_ApiKeyWithPolicyParams covers type: api-key configured +// via policyParams through the LlmProxy provider.auth call site. +func TestTransformProxy_ApiKeyWithPolicyParams(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + require.NoError(t, store.AddTemplate(template)) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-apikey-pp"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{Url: &upstreamURL}, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + providerAPI, err := transformer.Transform(provider, &api.RestAPI{}) + require.NoError(t, err) + + storedProvider := &models.StoredConfig{ + UUID: "0000-prov-cfg-3-0000-000000000000", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: "openai-provider-apikey-pp", + DisplayName: "OpenAI Provider", + Version: "v1.0", + Configuration: *providerAPI, + SourceConfiguration: *provider, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + db.SaveConfig(storedProvider) + require.NoError(t, store.Add(storedProvider)) + + proxy := &api.LLMProxyConfiguration{ + Metadata: api.Metadata{Name: "openai-proxy-apikey-pp"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "OpenAI Proxy (api-key via policyParams)", + Version: "v1.0", + Provider: api.LLMProxyProvider{ + Id: "openai-provider-apikey-pp", + Auth: &api.LLMUpstreamAuth{ + Type: api.LLMUpstreamAuthTypeApiKey, + PolicyParams: &map[string]interface{}{ + "request": map[string]interface{}{ + "headers": []interface{}{ + map[string]interface{}{"name": "X-Api-Key", "value": "sk-proxy-from-policyparams"}, + }, + }, + }, + }, + }, + }, + } + + result, err := transformer.Transform(proxy, &api.RestAPI{}) + require.NoError(t, err) + require.NotNil(t, result) + + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + var found *api.Policy + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME { + found = &p + break + } + } + require.NotNil(t, found, "operation %s %s should include the set-headers policy", op.EffectiveMethod(), op.EffectivePath()) + require.NotNil(t, found.Params) + request, ok := (*found.Params)["request"].(map[string]interface{}) + require.True(t, ok, "expected policyParams.request to survive verbatim, got %+v", *found.Params) + headers, ok := request["headers"].([]interface{}) + require.True(t, ok) + require.Len(t, headers, 1) + entry := headers[0].(map[string]interface{}) + assert.Equal(t, "X-Api-Key", entry["name"]) + assert.Equal(t, "sk-proxy-from-policyparams", entry["value"]) + } +} + +// TestTransformProxy_WithOAuth2PasswordGrantScope covers a password-grant +// policyParams (including a nested "params" map) reaching the built policy +// verbatim via the LlmProxy path. +func TestTransformProxy_WithOAuth2PasswordGrantScope(t *testing.T) { + store := storage.NewConfigStore() + db := newTestMockDB() + routerConfig := &config.RouterConfig{ListenerPort: 8080} + transformer := NewLLMProviderTransformer(store, db, routerConfig, newTestPolicyVersionResolver()) + + template := &models.StoredLLMProviderTemplate{ + UUID: "0000-template-1-0000-000000000000", + Configuration: api.LLMProviderTemplate{ + Metadata: api.Metadata{Name: "openai"}, + Spec: api.LLMProviderTemplateData{}, + }, + } + db.SaveLLMProviderTemplate(template) + err := store.AddTemplate(template) + require.NoError(t, err) + + upstreamURL := "https://api.openai.com" + provider := &api.LLMProviderConfiguration{ + Metadata: api.Metadata{Name: "openai-provider-oauth2-proxy-password"}, + Spec: api.LLMProviderConfigData{ + DisplayName: "OpenAI Provider", + Version: "v1.0", + Template: "openai", + Upstream: api.LLMProviderConfigData_Upstream{ + Url: &upstreamURL, + }, + AccessControl: api.LLMAccessControl{Mode: api.AllowAll}, + }, + } + + providerOut := &api.RestAPI{} + providerAPI, err := transformer.Transform(provider, providerOut) + require.NoError(t, err) + require.NotNil(t, providerAPI) + + storedProvider := &models.StoredConfig{ + UUID: "0000-prov-cfg-3-0000-000000000000", + Kind: string(api.LLMProviderConfigurationKindLlmProvider), + Handle: "openai-provider-oauth2-proxy-password", + DisplayName: "OpenAI Provider", + Version: "v1.0", + Configuration: *providerAPI, + SourceConfiguration: *provider, + DesiredState: models.StateDeployed, + Origin: models.OriginGatewayAPI, + } + db.SaveConfig(storedProvider) + err = store.Add(storedProvider) + require.NoError(t, err) + + tokenEndpoint := "https://idp.example.com/oauth2/token" + clientID := "proxy-client" + clientSecret := "proxy-secret" + username := "resource-owner" + password := "hunter2" + proxy := &api.LLMProxyConfiguration{ + Metadata: api.Metadata{Name: "openai-proxy-oauth2-password"}, + Spec: api.LLMProxyConfigData{ + DisplayName: "OpenAI Proxy (OAuth2 password grant)", + Version: "v1.0", + Provider: api.LLMProxyProvider{ + Id: "openai-provider-oauth2-proxy-password", + Auth: &api.LLMUpstreamAuth{ + Type: api.LLMUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "grantType": "password", + "tokenEndpoint": tokenEndpoint, + "clientId": clientID, + "clientSecret": clientSecret, + "username": username, + "password": password, + "params": map[string]string{"scope": "read write"}, + }, + }, + }, + }, + } + + output := &api.RestAPI{} + result, err := transformer.Transform(proxy, output) + require.NoError(t, err) + require.NotNil(t, result) + + require.NotEmpty(t, result.Spec.Operations) + for _, op := range result.Spec.Operations { + require.NotNil(t, op.Policies) + var foundParams *map[string]interface{} + for _, p := range *op.Policies { + if p.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + foundParams = p.Params + break + } + } + require.NotNil(t, foundParams) + assert.Equal(t, "password", (*foundParams)["grantType"]) + assert.Equal(t, username, (*foundParams)["username"]) + assert.Equal(t, password, (*foundParams)["password"]) + assert.Equal(t, map[string]string{"scope": "read write"}, (*foundParams)["params"]) + } +} + func TestTransformProvider_UnsupportedMode(t *testing.T) { store := storage.NewConfigStore() db := newTestMockDB() diff --git a/gateway/gateway-controller/pkg/utils/mcp_deployment.go b/gateway/gateway-controller/pkg/utils/mcp_deployment.go index 6fc1f12abf..b0d0b8a235 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_deployment.go +++ b/gateway/gateway-controller/pkg/utils/mcp_deployment.go @@ -57,16 +57,17 @@ type MCPDeploymentParams struct { // MCPDeploymentService provides utilities for MCP proxy configuration deployment type MCPDeploymentService struct { - store *storage.ConfigStore - db storage.Storage - snapshotManager *xds.SnapshotManager - parser *config.Parser - validator *config.MCPValidator - transformer Transformer - policyManager *policyxds.PolicyManager - eventHub eventhub.EventHub - gatewayID string - secretResolver funcs.SecretResolver + store *storage.ConfigStore + db storage.Storage + snapshotManager *xds.SnapshotManager + parser *config.Parser + validator *config.MCPValidator + transformer Transformer + policyVersionResolver PolicyVersionResolver + policyManager *policyxds.PolicyManager + eventHub eventhub.EventHub + gatewayID string + secretResolver funcs.SecretResolver // controlPlaneClient and deploymentPushEnabled drive the DP->CP push performed when a // gateway-originated MCP proxy is created here including via the immutable-gateway loader. @@ -84,6 +85,7 @@ func NewMCPDeploymentService( eventHub eventhub.EventHub, gatewayID string, secretResolver funcs.SecretResolver, + policyVersionResolver PolicyVersionResolver, ) *MCPDeploymentService { if db == nil { panic("MCPDeploymentService requires non-nil storage") @@ -91,16 +93,17 @@ func NewMCPDeploymentService( trimmedGatewayID := requireReplicaSyncWiring("MCPDeploymentService", eventHub, gatewayID) return &MCPDeploymentService{ - store: store, - db: db, - snapshotManager: snapshotManager, - parser: config.NewParser(), - validator: config.NewMCPValidator().WithPolicyValidator(policyValidator), - transformer: NewMCPTransformer(), - policyManager: policyManager, - eventHub: eventHub, - gatewayID: trimmedGatewayID, - secretResolver: secretResolver, + store: store, + db: db, + snapshotManager: snapshotManager, + parser: config.NewParser(), + validator: config.NewMCPValidator().WithPolicyValidator(policyValidator), + transformer: NewMCPTransformer(policyVersionResolver), + policyVersionResolver: policyVersionResolver, + policyManager: policyManager, + eventHub: eventHub, + gatewayID: trimmedGatewayID, + secretResolver: secretResolver, } } @@ -114,15 +117,17 @@ func (s *MCPDeploymentService) SetControlPlanePusher(pusher ArtifactPusher, push } // HydrateStoredMCPConfig rebuilds the derived RestAPI form for a stored MCP -// configuration from its canonical source document. -func HydrateStoredMCPConfig(cfg *models.StoredConfig) error { +// configuration from its canonical source document. Pass the gateway's real +// PolicyVersionResolver so an unpinned policyVersion resolves to this +// image's actually-loaded version instead of "". +func HydrateStoredMCPConfig(cfg *models.StoredConfig, resolver PolicyVersionResolver) error { if cfg == nil { return nil } if source, ok := cfg.SourceConfiguration.(api.MCPProxyConfiguration); ok { var restAPI api.RestAPI - if _, err := NewMCPTransformer().Transform(&source, &restAPI); err != nil { + if _, err := NewMCPTransformer(resolver).Transform(&source, &restAPI); err != nil { return fmt.Errorf("failed to transform stored MCP proxy %s: %w", cfg.UUID, err) } cfg.Configuration = restAPI @@ -166,7 +171,7 @@ func isMCPNotFoundError(err error) bool { } func (s *MCPDeploymentService) hydrateStoredMCPConfig(cfg *models.StoredConfig) { - if err := HydrateStoredMCPConfig(cfg); err != nil { + if err := HydrateStoredMCPConfig(cfg, s.policyVersionResolver); err != nil { configID := "" if cfg != nil { configID = cfg.UUID diff --git a/gateway/gateway-controller/pkg/utils/mcp_deployment_test.go b/gateway/gateway-controller/pkg/utils/mcp_deployment_test.go index cc35832cbe..060a4e58a7 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_deployment_test.go +++ b/gateway/gateway-controller/pkg/utils/mcp_deployment_test.go @@ -31,6 +31,7 @@ import ( "github.com/stretchr/testify/require" "github.com/wso2/api-platform/common/eventhub" api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/constants" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" ) @@ -248,7 +249,7 @@ func TestMCPDeploymentService_GetMCPProxyByHandle(t *testing.T) { CreatedAt: time.Now(), UpdatedAt: time.Now(), } - require.NoError(t, HydrateStoredMCPConfig(cfg)) + require.NoError(t, HydrateStoredMCPConfig(cfg, nil)) require.NoError(t, db.SaveConfig(cfg)) found, err := service.GetMCPProxyByHandle("test-mcp") @@ -666,7 +667,7 @@ func TestMCPDeploymentService_UndeployMCPProxy_WithDBAndEventHubPublishesUpdate( CreatedAt: time.Now(), UpdatedAt: time.Now(), } - require.NoError(t, HydrateStoredMCPConfig(cfg)) + require.NoError(t, HydrateStoredMCPConfig(cfg, nil)) require.NoError(t, db.SaveConfig(cfg)) require.NoError(t, store.Add(cfg)) @@ -695,3 +696,57 @@ func TestMCPDeploymentService_UndeployMCPProxy_WithDBAndEventHubPublishesUpdate( assert.Equal(t, cfg.UUID, mockHub.publishedEvents[0].event.EntityID) assert.Equal(t, "corr-mcp-undeploy", mockHub.publishedEvents[0].event.EventID) } + + +// A real resolver must resolve an unpinned oauth2 auth policy to this +// gateway's actually-loaded version, not "". +func TestHydrateStoredMCPConfig_ResolvesUnpinnedVersionWithRealResolver(t *testing.T) { + url := "https://idp.example.com" + + cfg := &models.StoredConfig{ + UUID: "mcp-1", + SourceConfiguration: api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "test-mcp", + Version: "1.0.0", + Context: stringPtr("/mcp"), + SpecVersion: func() *string { v := LATEST_SUPPORTED_MCP_SPEC_VERSION; return &v }(), + Upstream: api.MCPProxyConfigData_Upstream{ + Url: &url, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": "https://idp.example.com/oauth2/token", + "clientId": "client-id", + "clientSecret": "client-secret", + }, + // No PolicyVersion override - the common case. + }, + }, + }, + }, + } + + err := HydrateStoredMCPConfig(cfg, newTestPolicyVersionResolver()) + require.NoError(t, err) + + restAPI, ok := cfg.Configuration.(api.RestAPI) + require.True(t, ok) + require.NotNil(t, restAPI.Spec.Policies) + + var found bool + for _, p := range *restAPI.Spec.Policies { + if p.Name == constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME { + assert.NotEmpty(t, p.Version) + found = true + } + } + assert.True(t, found, "expected an oauth2-generator policy in the hydrated config") +} diff --git a/gateway/gateway-controller/pkg/utils/mcp_transformer.go b/gateway/gateway-controller/pkg/utils/mcp_transformer.go index 7a6bcd5891..809c818824 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_transformer.go +++ b/gateway/gateway-controller/pkg/utils/mcp_transformer.go @@ -27,10 +27,50 @@ import ( ) type MCPTransformer struct { + // policyVersionResolver validates a caller-supplied upstream.auth.policyVersion + // override against what's loaded in this gateway image. nil (e.g. the + // HydrateStoredMCPConfig rehydration path) skips that validation. + policyVersionResolver PolicyVersionResolver } -func NewMCPTransformer() *MCPTransformer { - return &MCPTransformer{} +func NewMCPTransformer(policyVersionResolver PolicyVersionResolver) *MCPTransformer { + return &MCPTransformer{policyVersionResolver: policyVersionResolver} +} + +// resolvePolicyVersionOverride mirrors LLMProviderTransformer's method of the +// same name, except a nil policyVersionResolver here is not an error - the +// override is accepted as-is rather than validated. +func (t *MCPTransformer) resolvePolicyVersionOverride(name string, override *string) (string, error) { + if t.policyVersionResolver == nil { + if override != nil { + return strings.TrimSpace(*override), nil + } + return "", nil + } + resolved, err := t.policyVersionResolver.Resolve(name) + if err != nil { + return "", err + } + if override == nil { + return resolved, nil + } + trimmed := strings.TrimSpace(*override) + if trimmed == "" || trimmed == resolved { + return resolved, nil + } + return "", fmt.Errorf("policy '%s' version '%s' was requested, but this gateway build only has '%s' loaded", name, trimmed, resolved) +} + +// buildSetHeadersParams validates header/value and builds the set-headers +// policy params from them - shared by the api-key and bearer cases below. +func buildSetHeadersParams(header, value *string) (map[string]interface{}, error) { + if header == nil || *header == "" { + return nil, fmt.Errorf("upstream.auth.header is required") + } + if value == nil || *value == "" { + return nil, fmt.Errorf("upstream.auth.value is required") + } + return GetParamsOfPolicy(constants.SET_HEADERS_POLICY_PARAMS, *header, *value) } // protocolVersionComparator compares two MCP protocol version strings in YYYY-MM-DD format @@ -194,15 +234,69 @@ func (t *MCPTransformer) Transform(input any, output *api.RestAPI) (*api.RestAPI // Set upstream auth if present upstream := mcpConfig.Spec.Upstream if upstream.Auth != nil { - params, err := GetParamsOfPolicy(constants.SET_HEADERS_POLICY_PARAMS, *upstream.Auth.Header, *upstream.Auth.Value) - if err != nil { - return nil, fmt.Errorf("failed to build upstream auth params: %w", err) - } - pol := api.Policy{ - Name: constants.SET_HEADERS_POLICY_NAME, - Params: ¶ms, + auth := upstream.Auth + switch auth.Type { + case api.MCPProxyConfigDataUpstreamAuthTypeApiKey: + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + constants.SET_HEADERS_POLICY_NAME, + func() (map[string]interface{}, error) { + params, err := buildSetHeadersParams(auth.Header, auth.Value) + if err != nil { + return nil, fmt.Errorf("failed to build upstream auth params: %w", err) + } + return params, nil + }, + t.resolvePolicyVersionOverride, + ) + if err != nil { + return nil, err + } + policies = append(policies, *pol) + case api.MCPProxyConfigDataUpstreamAuthTypeOauth2: + // No typed-field fallback for oauth2 - policyParams is always required. + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME, nil, t.resolvePolicyVersionOverride) + if err != nil { + return nil, err + } + policies = append(policies, *pol) + case api.MCPProxyConfigDataUpstreamAuthTypeOther: + // No default policy name (policyName is required) and no typed-field + // fallback (policyParams is always required). + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, auth.PolicyParams, + "", nil, t.resolvePolicyVersionOverride) + if err != nil { + return nil, err + } + policies = append(policies, *pol) + case api.MCPProxyConfigDataUpstreamAuthTypeNone: + // No upstream authentication - no auth policy is attached; auth + // (if any) is handled entirely by user-attached policies elsewhere. + case api.MCPProxyConfigDataUpstreamAuthType("bearer"): + // Preserved for backward compatibility (see mcp_validator.go); policyParams + // is always nil here, bearer has no policyParams form of its own. + pol, err := buildUpstreamAuthPolicy(string(auth.Type), "upstream.auth", + auth.PolicyName, auth.PolicyVersion, nil, + constants.SET_HEADERS_POLICY_NAME, + func() (map[string]interface{}, error) { + params, err := buildSetHeadersParams(auth.Header, auth.Value) + if err != nil { + return nil, fmt.Errorf("failed to build upstream auth params: %w", err) + } + return params, nil + }, + t.resolvePolicyVersionOverride, + ) + if err != nil { + return nil, err + } + policies = append(policies, *pol) + default: + return nil, fmt.Errorf("unsupported upstream auth type: %s", auth.Type) } - policies = append(policies, pol) } apiData.Policies = &policies diff --git a/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go b/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go index 81eeb55318..676fd81d43 100644 --- a/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go +++ b/gateway/gateway-controller/pkg/utils/mcp_transformer_test.go @@ -166,14 +166,17 @@ func TestMCPTransformer_Transform_WithPoliciesAndUpstreamAuth(t *testing.T) { url := "http://backend:8080" authHeader := "Authorization" authValue := "Bearer token-xyz" - authType := api.MCPProxyConfigDataUpstreamAuthType("bearer") + authType := api.MCPProxyConfigDataUpstreamAuthTypeApiKey upstream := api.MCPProxyConfigData_Upstream{ Url: &url, Auth: &struct { - Header *string `json:"header,omitempty" yaml:"header,omitempty"` - Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` - Value *string `json:"value,omitempty" yaml:"value,omitempty"` + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` }{ Header: &authHeader, Type: authType, @@ -200,7 +203,7 @@ func TestMCPTransformer_Transform_WithPoliciesAndUpstreamAuth(t *testing.T) { } var out api.RestAPI - tr := &MCPTransformer{} + tr := NewMCPTransformer(newTestPolicyVersionResolver()) res, err := tr.Transform(in, &out) if err != nil { t.Fatalf("Transform returned an error: %v", err) @@ -228,15 +231,270 @@ func TestMCPTransformer_Transform_WithPoliciesAndUpstreamAuth(t *testing.T) { } } +// "bearer" predates the shared api-key/oauth2/other/none contract - preserved +// for MCP backward compatibility, see mcp_validator.go. +func TestMCPTransformer_Transform_WithBearerUpstreamAuth_BackwardCompat(t *testing.T) { + name := "petstore" + version := "1.0.0" + context := "/petstore" + url := "http://backend:8080" + authHeader := "Authorization" + authValue := "Bearer token-xyz" + authType := api.MCPProxyConfigDataUpstreamAuthType("bearer") + + upstream := api.MCPProxyConfigData_Upstream{ + Url: &url, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Header: &authHeader, + Type: authType, + Value: &authValue, + }, + } + + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: name, + Version: version, + Context: &context, + Upstream: upstream, + SpecVersion: &latest, + }, + } + + var out api.RestAPI + tr := NewMCPTransformer(newTestPolicyVersionResolver()) + res, err := tr.Transform(in, &out) + if err != nil { + t.Fatalf("Transform returned an error: %v", err) + } + + apiData := res.Spec + if apiData.Policies == nil { + t.Fatalf("Expected policies to be present") + } + + resPolicies := *apiData.Policies + if len(resPolicies) != 1 { + t.Fatalf("Expected 1 policy, got %d", len(resPolicies)) + } + if resPolicies[0].Name != constants.SET_HEADERS_POLICY_NAME { + t.Errorf("Expected policy to be %s, got %s", constants.SET_HEADERS_POLICY_NAME, resPolicies[0].Name) + } +} + +func TestMCPTransformer_Transform_WithOAuth2UpstreamAuth(t *testing.T) { + name := "petstore" + version := "1.0.0" + context := "/petstore" + url := "http://backend:8080" + authType := api.MCPProxyConfigDataUpstreamAuthTypeOauth2 + tokenEndpoint := "https://idp.example.com/oauth2/token" + clientID := "client-id" + clientSecret := "client-secret" + + upstream := api.MCPProxyConfigData_Upstream{ + Url: &url, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: authType, + PolicyParams: &map[string]interface{}{ + "tokenEndpoint": tokenEndpoint, + "clientId": clientID, + "clientSecret": clientSecret, + }, + }, + } + + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: name, + Version: version, + Context: &context, + Upstream: upstream, + SpecVersion: &latest, + }, + } + + var out api.RestAPI + tr := NewMCPTransformer(newTestPolicyVersionResolver()) + res, err := tr.Transform(in, &out) + require.NoError(t, err) + + apiData := res.Spec + require.NotNil(t, apiData.Policies) + resPolicies := *apiData.Policies + require.Len(t, resPolicies, 1) + + pol := resPolicies[0] + assert.Equal(t, constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME, pol.Name) + require.NotNil(t, pol.Params) + params := *pol.Params + // policyParams is forwarded verbatim - no CRD-level defaulting of + // grantType; that's the oauth2-generator policy's own responsibility. + assert.Equal(t, tokenEndpoint, params["tokenEndpoint"]) + assert.Equal(t, clientID, params["clientId"]) + assert.Equal(t, clientSecret, params["clientSecret"]) +} + +// TestMCPTransformer_Transform_PolicyVersionOverride covers a matching and a +// mismatched policyVersion pin with a real policyVersionResolver wired in. +func TestMCPTransformer_Transform_PolicyVersionOverride(t *testing.T) { + newConfig := func(policyVersion *string) *api.MCPProxyConfiguration { + context := "/petstore" + url := "http://backend:8080" + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + return &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "petstore", + Version: "1.0.0", + Context: &context, + SpecVersion: &latest, + Upstream: api.MCPProxyConfigData_Upstream{ + Url: &url, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: api.MCPProxyConfigDataUpstreamAuthTypeOauth2, + PolicyParams: &map[string]interface{}{"tokenEndpoint": "https://idp.example.com/oauth2/token"}, + PolicyVersion: policyVersion, + }, + }, + }, + } + } + + t.Run("matching pin succeeds", func(t *testing.T) { + tr := NewMCPTransformer(newTestPolicyVersionResolver()) + var out api.RestAPI + res, err := tr.Transform(newConfig(stringPtr(testOAuth2AuthenticationVersion)), &out) + require.NoError(t, err) + require.NotNil(t, res.Spec.Policies) + resPolicies := *res.Spec.Policies + require.Len(t, resPolicies, 1) + assert.Equal(t, testOAuth2AuthenticationVersion, resPolicies[0].Version) + }) + + t.Run("mismatched pin fails loudly instead of silently using the loaded version", func(t *testing.T) { + tr := NewMCPTransformer(newTestPolicyVersionResolver()) + var out api.RestAPI + _, err := tr.Transform(newConfig(stringPtr("v1")), &out) + require.Error(t, err) + assert.Contains(t, err.Error(), "v1") + assert.Contains(t, err.Error(), testOAuth2AuthenticationVersion) + }) +} + +// TestMCPTransformer_Transform_WithOAuth2UpstreamAuth_MissingPolicyParams locks +// in the only CRD-level requirement for type: oauth2 - policyParams must be present. +func TestMCPTransformer_Transform_WithOAuth2UpstreamAuth_MissingPolicyParams(t *testing.T) { + context := "/petstore" + url := "http://backend:8080" + authType := api.MCPProxyConfigDataUpstreamAuthTypeOauth2 + + upstream := api.MCPProxyConfigData_Upstream{ + Url: &url, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: authType, + // PolicyParams deliberately omitted. + }, + } + + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "petstore", + Version: "1.0.0", + Context: &context, + Upstream: upstream, + SpecVersion: &latest, + }, + } + + var out api.RestAPI + tr := &MCPTransformer{} + _, err := tr.Transform(in, &out) + require.Error(t, err) + assert.Contains(t, err.Error(), "policyParams") +} + +// TestMCPTransformer_Transform_WithNoneUpstreamAuth locks in that type: none +// is a no-op at transform time - no auth policy is attached. +func TestMCPTransformer_Transform_WithNoneUpstreamAuth(t *testing.T) { + context := "/petstore" + url := "http://backend:8080" + authType := api.MCPProxyConfigDataUpstreamAuthTypeNone + + upstream := api.MCPProxyConfigData_Upstream{ + Url: &url, + Auth: &struct { + Header *string `json:"header,omitempty" yaml:"header,omitempty"` + PolicyName *string `json:"policyName,omitempty" yaml:"policyName,omitempty"` + PolicyParams *map[string]interface{} `json:"policyParams,omitempty" yaml:"policyParams,omitempty"` + PolicyVersion *string `json:"policyVersion,omitempty" yaml:"policyVersion,omitempty"` + Type api.MCPProxyConfigDataUpstreamAuthType `json:"type" yaml:"type"` + Value *string `json:"value,omitempty" yaml:"value,omitempty"` + }{ + Type: authType, + }, + } + + latest := LATEST_SUPPORTED_MCP_SPEC_VERSION + in := &api.MCPProxyConfiguration{ + Spec: api.MCPProxyConfigData{ + DisplayName: "petstore", + Version: "1.0.0", + Context: &context, + Upstream: upstream, + SpecVersion: &latest, + }, + } + + var out api.RestAPI + tr := &MCPTransformer{} + res, err := tr.Transform(in, &out) + require.NoError(t, err) + + apiData := res.Spec + require.NotNil(t, apiData.Policies) + assert.Empty(t, *apiData.Policies) +} + func TestNewMCPTransformer(t *testing.T) { - tr := NewMCPTransformer() + tr := NewMCPTransformer(nil) if tr == nil { t.Fatal("Expected non-nil MCPTransformer") } } func TestMCPTransformer_Transform_InvalidInput(t *testing.T) { - tr := NewMCPTransformer() + tr := NewMCPTransformer(nil) var out api.RestAPI // Test with nil input diff --git a/gateway/gateway-controller/pkg/utils/policy_version_resolver_test.go b/gateway/gateway-controller/pkg/utils/policy_version_resolver_test.go index 87366a9455..93ef8005d2 100644 --- a/gateway/gateway-controller/pkg/utils/policy_version_resolver_test.go +++ b/gateway/gateway-controller/pkg/utils/policy_version_resolver_test.go @@ -12,14 +12,21 @@ import ( ) const ( - testSetHeadersVersion = "v9.9.9" - testRespondVersion = "v9.9.8" + testSetHeadersVersion = "v9.9.9" + testRespondVersion = "v9.9.8" + testOAuth2AuthenticationVersion = "v9.9.7" + testCustomAuthPolicyVersion = "v9.9.6" ) +// testCustomAuthPolicyName is the example policy name used for auth type "other". +const testCustomAuthPolicyName = "my-custom-auth-policy" + func newTestPolicyVersionResolver() PolicyVersionResolver { return NewStaticPolicyVersionResolver(map[string]string{ constants.UPSTREAM_AUTH_APIKEY_POLICY_NAME: testSetHeadersVersion, constants.ACCESS_CONTROL_DENY_POLICY_NAME: testRespondVersion, + constants.UPSTREAM_AUTH_OAUTH2_POLICY_NAME: testOAuth2AuthenticationVersion, + testCustomAuthPolicyName: testCustomAuthPolicyVersion, }) } diff --git a/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go b/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go index 3a4d933350..420a7c6755 100644 --- a/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go +++ b/gateway/gateway-controller/pkg/utils/replica_sync_dependencies_test.go @@ -45,7 +45,7 @@ func TestConstructorReplicaSyncWiring(t *testing.T) { }) t.Run("mcp deployment stores constructor wiring", func(t *testing.T) { - service := NewMCPDeploymentService(store, db, nil, nil, nil, newReplicaSyncTestEventHub(), " gateway-3 ", nil) + service := NewMCPDeploymentService(store, db, nil, nil, nil, newReplicaSyncTestEventHub(), " gateway-3 ", nil, nil) require.NotNil(t, service.eventHub) assert.Equal(t, "gateway-3", service.gatewayID) }) diff --git a/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go b/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go index 19f4124368..0863e3e6a6 100644 --- a/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go +++ b/gateway/gateway-controller/pkg/utils/replica_sync_test_helpers_test.go @@ -105,5 +105,6 @@ func newTestMCPDeploymentServiceWithHub( hub, gatewayID, nil, + nil, ) } diff --git a/gateway/it/docker-compose.test.postgres.yaml b/gateway/it/docker-compose.test.postgres.yaml index a45b3b5f25..7ea2d4286f 100644 --- a/gateway/it/docker-compose.test.postgres.yaml +++ b/gateway/it/docker-compose.test.postgres.yaml @@ -268,6 +268,23 @@ services: networks: - it-gateway-runtime-network + # Mock OAuth2 identity provider for oauth2-generator policy testing + mock-oauth2-idp: + container_name: it-mock-oauth2-idp + image: ghcr.io/wso2/api-platform/mock-oauth2-idp:latest + build: + context: ../../tests/mock-servers/mock-oauth2-idp + dockerfile: Dockerfile + environment: + - CLIENT_ID=test-client + - CLIENT_SECRET=test-secret + - RESOURCE_OWNER_USERNAME=resource-owner + - RESOURCE_OWNER_PASSWORD=hunter2 + ports: + - "8088:9601" + networks: + - it-gateway-runtime-network + # Generic mock server for various test scenarios mock-openapi: container_name: it-mock-openapi diff --git a/gateway/it/docker-compose.test.sqlserver.yaml b/gateway/it/docker-compose.test.sqlserver.yaml index c0e16923d1..12931ef129 100644 --- a/gateway/it/docker-compose.test.sqlserver.yaml +++ b/gateway/it/docker-compose.test.sqlserver.yaml @@ -248,6 +248,23 @@ services: networks: - it-gateway-runtime-network + # Mock OAuth2 identity provider for oauth2-generator policy testing + mock-oauth2-idp: + container_name: it-mock-oauth2-idp + image: ghcr.io/wso2/api-platform/mock-oauth2-idp:latest + build: + context: ../../tests/mock-servers/mock-oauth2-idp + dockerfile: Dockerfile + environment: + - CLIENT_ID=test-client + - CLIENT_SECRET=test-secret + - RESOURCE_OWNER_USERNAME=resource-owner + - RESOURCE_OWNER_PASSWORD=hunter2 + ports: + - "8088:9601" + networks: + - it-gateway-runtime-network + # Generic mock server for various test scenarios mock-openapi: container_name: it-mock-openapi diff --git a/gateway/it/docker-compose.test.yaml b/gateway/it/docker-compose.test.yaml index 279ca81fda..b9bd510424 100644 --- a/gateway/it/docker-compose.test.yaml +++ b/gateway/it/docker-compose.test.yaml @@ -183,6 +183,23 @@ services: networks: - it-gateway-runtime-network + # Mock OAuth2 identity provider for oauth2-generator policy testing + mock-oauth2-idp: + container_name: it-mock-oauth2-idp + image: ghcr.io/wso2/api-platform/mock-oauth2-idp:latest + build: + context: ../../tests/mock-servers/mock-oauth2-idp + dockerfile: Dockerfile + environment: + - CLIENT_ID=test-client + - CLIENT_SECRET=test-secret + - RESOURCE_OWNER_USERNAME=resource-owner + - RESOURCE_OWNER_PASSWORD=hunter2 + ports: + - "8088:9601" + networks: + - it-gateway-runtime-network + # Generic mock server for various test scenarios mock-openapi: container_name: it-mock-openapi diff --git a/gateway/it/features/oauth2-auth.feature b/gateway/it/features/oauth2-auth.feature new file mode 100644 index 0000000000..176fd70985 --- /dev/null +++ b/gateway/it/features/oauth2-auth.feature @@ -0,0 +1,532 @@ +# -------------------------------------------------------------------- +# 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. +# -------------------------------------------------------------------- + +@oauth2-auth +Feature: OAuth2 Upstream Authentication + As an API developer + I want the gateway to fetch, cache, and inject OAuth2 credentials on my behalf + So that my backend can require OAuth2 without the client ever handling that credential + + # Every API below carries an unattached, always-200 "/health" operation used + # only to wait for xDS propagation - the "/data" operation under test is + # deliberately allowed to return non-200 (502, 401, ...) in several + # scenarios, so it can never be used as the readiness probe itself (see + # iWaitForEndpointToBeReady, which polls for exactly 200). + + Background: + Given the gateway services are running + And I authenticate using basic auth as "admin" + And I send a POST request to the "mock-oauth2-idp" service at "/debug/reset" with body: + """ + {} + """ + + Scenario: Token-endpoint grant happy path injects a Bearer token + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-happy-path + spec: + displayName: OAuth2 IT Happy Path + version: v1.0 + context: /oauth2-it-happy-path/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-happy-path/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-happy-path/v1.0/data" + Then the response status code should be 200 + And the response should contain echoed header "Authorization" containing "Bearer mock-token-" + + When I send a GET request to the "mock-oauth2-idp" service at "/debug/stats" + Then the JSON response field "tokenRequestCount" should be 1 + + Scenario: Password grant happy path + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-password-grant + spec: + displayName: OAuth2 IT Password Grant + version: v1.0 + context: /oauth2-it-password-grant/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + grantType: password + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + username: resource-owner + password: hunter2 + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-password-grant/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-password-grant/v1.0/data" + Then the response status code should be 200 + And the response should contain echoed header "Authorization" containing "Bearer mock-token-" + + Scenario: client_secret_post authentication reaches the token endpoint + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-client-secret-post + spec: + displayName: OAuth2 IT client_secret_post + version: v1.0 + context: /oauth2-it-client-secret-post/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + clientAuthMethod: client_secret_post + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-client-secret-post/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-client-secret-post/v1.0/data" + Then the response status code should be 200 + + When I send a GET request to the "mock-oauth2-idp" service at "/debug/stats" + Then the response body should match pattern "authStyle.{3}post" + + Scenario: Custom headerName and valuePrefix are applied + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-custom-header + spec: + displayName: OAuth2 IT Custom Header + version: v1.0 + context: /oauth2-it-custom-header/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + headerName: X-Upstream-Token + valuePrefix: "" + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-custom-header/v1.0/health" to be ready + + # Background's basic-auth step leaves a persistent Authorization header on + # the client (used for the management-API deploy call above) - clear it so + # the assertion below reflects what the policy actually did, not a + # leftover test-client header riding along on this unrelated request. + Given I clear all headers + When I send a GET request to "http://localhost:8080/oauth2-it-custom-header/v1.0/data" + Then the response status code should be 200 + And the response should contain echoed header "X-Upstream-Token" containing "mock-token-" + And the response should not contain echoed header "Authorization" + + Scenario: bearerToken static path never calls the token endpoint + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-bearer-token + spec: + displayName: OAuth2 IT Bearer Token + version: v1.0 + context: /oauth2-it-bearer-token/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + bearerToken: static-long-lived-token-xyz + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-bearer-token/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-bearer-token/v1.0/data" + Then the response status code should be 200 + And the response should contain echoed header "Authorization" with value "Bearer static-long-lived-token-xyz" + + When I send a GET request to the "mock-oauth2-idp" service at "/debug/stats" + Then the JSON response field "tokenRequestCount" should be 0 + + Scenario: Token is cached across repeated requests + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-caching + spec: + displayName: OAuth2 IT Caching + version: v1.0 + context: /oauth2-it-caching/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + # ?ttl=3600 (not the mock's 300s default) - 300s equals the + # default expiryBuffer, so a default-TTL token would be + # treated as stale immediately and refetched on every + # request, defeating the very thing this scenario checks. + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token?ttl=3600 + clientId: test-client + clientSecret: test-secret + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-caching/v1.0/health" to be ready + + When I send 5 GET requests to "http://localhost:8080/oauth2-it-caching/v1.0/data" + Then the response status code should be 200 + + When I send a GET request to the "mock-oauth2-idp" service at "/debug/stats" + Then the JSON response field "tokenRequestCount" should be 1 + + Scenario: Invalid client credentials return a Bad Gateway + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-invalid-client + spec: + displayName: OAuth2 IT Invalid Client + version: v1.0 + context: /oauth2-it-invalid-client/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: definitely-the-wrong-secret + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-invalid-client/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-invalid-client/v1.0/data" + Then the response status code should be 502 + + Scenario: Unreachable token endpoint returns a Bad Gateway + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-unreachable + spec: + displayName: OAuth2 IT Unreachable IdP + version: v1.0 + context: /oauth2-it-unreachable/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp-does-not-exist:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + tokenRequestTimeout: 3s + tokenRequestMaxRetries: 0 + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-unreachable/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-unreachable/v1.0/data" + Then the response status code should be 502 + + Scenario: Malformed token-endpoint response returns a Bad Gateway + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-malformed + spec: + displayName: OAuth2 IT Malformed Response + version: v1.0 + context: /oauth2-it-malformed/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: malformed-client + clientSecret: any-secret + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-malformed/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-malformed/v1.0/data" + Then the response status code should be 502 + + Scenario: tokenRequestParams reaches the token endpoint + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-token-request-params + spec: + displayName: OAuth2 IT tokenRequestParams + version: v1.0 + context: /oauth2-it-token-request-params/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + tokenRequestParams: + scope: it-suite-scope + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-token-request-params/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-token-request-params/v1.0/data" + Then the response status code should be 200 + + When I send a GET request to the "mock-oauth2-idp" service at "/debug/stats" + Then the response body should contain "it-suite-scope" + + Scenario: tokenRequestHeaders reaches the token endpoint + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-token-request-headers + spec: + displayName: OAuth2 IT tokenRequestHeaders + version: v1.0 + context: /oauth2-it-token-request-headers/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /data + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + tokenRequestHeaders: + X-IT-Suite-Header: it-suite-value + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-token-request-headers/v1.0/health" to be ready + + When I send a GET request to "http://localhost:8080/oauth2-it-token-request-headers/v1.0/data" + Then the response status code should be 200 + + When I send a GET request to the "mock-oauth2-idp" service at "/debug/stats" + Then the response body should contain "it-suite-value" + + # NOTE: a purge-on-401 scenario (prime cache, force a 401 via sample-backend's + # ?statusCode= query param, confirm the cache is cleared and the next request + # fetches fresh) was deliberately left out here. It requires reliably forcing + # a specific upstream status code through the full router path, which didn't + # behave predictably in this suite (no retry mechanism exists in this + # codebase - confirmed by grepping gateway-controller/gateway-runtime for any + # Envoy RetryPolicy/resilience.retry generation, and by direct confirmation - + # so a mismatch here isn't a retry side effect, it's something about how the + # query string reaches the backend that wasn't worth chasing further for one + # scenario). oauth2_generator.go's own unit tests + # (TestGetPolicy_PurgeOnUpstreamStatus_EndToEnd) already cover this behavior + # directly and reliably, without depending on a real HTTP round-trip. + + Scenario: An operation without the policy attached is unaffected + When I deploy this API configuration: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: RestApi + metadata: + name: oauth2-it-sibling-unaffected + spec: + displayName: OAuth2 IT Sibling Unaffected + version: v1.0 + context: /oauth2-it-sibling-unaffected/$version + upstream: + main: + url: http://sample-backend:9080 + operations: + - method: GET + path: /health + - method: GET + path: /protected + policies: + - name: oauth2-generator + version: v0 + params: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + """ + Then the response should be successful + And I wait for the endpoint "http://localhost:8080/oauth2-it-sibling-unaffected/v1.0/health" to be ready + + # See the "Custom headerName" scenario above for why this is needed. + Given I clear all headers + When I send a GET request to "http://localhost:8080/oauth2-it-sibling-unaffected/v1.0/health" + Then the response status code should be 200 + And the response should not contain echoed header "Authorization" + + Scenario: LlmProvider upstream.auth wires the same policy via the CRD convenience field + When I create this LLM provider: + """ + apiVersion: gateway.api-platform.wso2.com/v1 + kind: LlmProvider + metadata: + name: oauth2-it-llm-provider + spec: + displayName: OAuth2 IT LLM Provider + version: v1.0 + template: openai + context: /oauth2-it-llm-provider/latest + upstream: + url: http://sample-backend:9080 + auth: + type: oauth2 + policyParams: + tokenEndpoint: http://mock-oauth2-idp:9601/oauth2/token + clientId: test-client + clientSecret: test-secret + accessControl: + mode: allow_all + """ + Then the response status code should be 201 + And I wait for the endpoint "http://localhost:8080/oauth2-it-llm-provider/latest/chat/completions" to be ready with method "POST" and body '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}' + + When I send a POST request to "http://localhost:8080/oauth2-it-llm-provider/latest/chat/completions" with body: + """ + { + "model": "gpt-4", + "messages": [{"role": "user", "content": "hi"}] + } + """ + Then the response status code should be 200 + And the response should contain echoed header "Authorization" containing "Bearer mock-token-" + + # Cleanup + Given I authenticate using basic auth as "admin" + When I delete the LLM provider "oauth2-it-llm-provider" + Then the response status code should be 200 diff --git a/gateway/it/state.go b/gateway/it/state.go index 910799d85e..98f027dda0 100644 --- a/gateway/it/state.go +++ b/gateway/it/state.go @@ -48,6 +48,7 @@ type Config struct { SampleBackendURL string EchoBackendURL string MockJWKSURL string + MockOAuth2IdPURL string MockAzureContentSafetyURL string MockAWSBedrockGuardrailURL string MockEmbeddingProviderURL string @@ -63,6 +64,9 @@ const MockPlatformAPIPort = "9244" // MockJWKSPort is the port for mock-jwks service const MockJWKSPort = "8082" +// MockOAuth2IdPPort is the port for mock-oauth2-idp service +const MockOAuth2IdPPort = "8088" + // MockAzureContentSafetyPort is the port for mock-azure-content-safety service const MockAzureContentSafetyPort = "8084" @@ -95,6 +99,7 @@ func DefaultConfig() *Config { SampleBackendURL: "http://localhost:9080", EchoBackendURL: "http://localhost:9081", MockJWKSURL: fmt.Sprintf("http://localhost:%s", MockJWKSPort), + MockOAuth2IdPURL: fmt.Sprintf("http://localhost:%s", MockOAuth2IdPPort), MockAzureContentSafetyURL: fmt.Sprintf("http://localhost:%s", MockAzureContentSafetyPort), MockAWSBedrockGuardrailURL: fmt.Sprintf("http://localhost:%s", MockAWSBedrockGuardrailPort), MockEmbeddingProviderURL: fmt.Sprintf("http://localhost:%s", MockEmbeddingProviderPort), diff --git a/gateway/it/suite_test.go b/gateway/it/suite_test.go index beff2452dc..d2ad7bd9bb 100644 --- a/gateway/it/suite_test.go +++ b/gateway/it/suite_test.go @@ -94,6 +94,7 @@ func getFeaturePaths() []string { "features/mcp_policies.feature", "features/ratelimit.feature", "features/jwt-auth.feature", + "features/oauth2-auth.feature", "features/cors.feature", "features/word-count-guardrail.feature", "features/sentence-count-guardrail.feature", @@ -245,6 +246,7 @@ func InitializeTestSuite(ctx *godog.TestSuiteContext) { "sample-backend": testState.Config.SampleBackendURL, "echo-backend": testState.Config.EchoBackendURL, "mock-jwks": testState.Config.MockJWKSURL, + "mock-oauth2-idp": testState.Config.MockOAuth2IdPURL, "mock-azure-content-safety": testState.Config.MockAzureContentSafetyURL, "mock-aws-bedrock-guardrail": testState.Config.MockAWSBedrockGuardrailURL, "mock-embedding-provider": testState.Config.MockEmbeddingProviderURL, diff --git a/tests/mock-servers/mock-oauth2-idp/Dockerfile b/tests/mock-servers/mock-oauth2-idp/Dockerfile new file mode 100644 index 0000000000..1a918b45a9 --- /dev/null +++ b/tests/mock-servers/mock-oauth2-idp/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.26.5-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ + +COPY . . +RUN go build -o mock-oauth2-idp main.go + +FROM alpine:latest + +WORKDIR /app +COPY --from=builder /app/mock-oauth2-idp . + +EXPOSE 9601 +CMD ["./mock-oauth2-idp"] diff --git a/tests/mock-servers/mock-oauth2-idp/go.mod b/tests/mock-servers/mock-oauth2-idp/go.mod new file mode 100644 index 0000000000..4299ad672c --- /dev/null +++ b/tests/mock-servers/mock-oauth2-idp/go.mod @@ -0,0 +1,3 @@ +module mock-oauth2-idp + +go 1.26.5 diff --git a/tests/mock-servers/mock-oauth2-idp/main.go b/tests/mock-servers/mock-oauth2-idp/main.go new file mode 100644 index 0000000000..8442463d22 --- /dev/null +++ b/tests/mock-servers/mock-oauth2-idp/main.go @@ -0,0 +1,428 @@ +// Command mock-oauth2-idp is a minimal, in-memory OAuth2 identity provider +// used to manually test the oauth2 gateway policy end to end. It is +// intentionally NOT a spec-complete OAuth2 server — it implements exactly +// the surface the policy needs for both grants it supports (RFC 6749 +// Section 4.4 client_credentials and Section 4.3 password), plus a small +// debug API so test flows can assert on gateway behavior (caching, +// refresh, failure handling) from the outside. +// +// Configured clients: +// - valid client: id= secret= -> 200 OK, fresh/cached token +// - broken client: id="broken-client" -> 500 Internal Server Error (simulates IdP outage) +// - malformed client: id="malformed-client" -> 200 OK, body missing access_token +// - any other id/secret combination -> 400, {"error":"invalid_client"} +// +// For grant_type=password, the resource owner's username/password are +// additionally checked against RESOURCE_OWNER_USERNAME/RESOURCE_OWNER_PASSWORD +// (default "resource-owner"/"hunter2") - a mismatch returns 400 invalid_grant. +// +// CLIENT_ID / CLIENT_SECRET default to "test-client" / "test-secret" and can +// be overridden via environment variables of the same name. +// +// Endpoints: +// +// POST /oauth2/token grant_type=client_credentials or password, client_secret_basic +// OR client_secret_post, optional `ttl` (seconds, default 300), +// `scope`, `delayMs` (artificially delay the response - test +// tokenRequestTimeout), `omitExpiresIn` (drop expires_in +// from the response entirely - test defaultTokenTTL), and +// `failFirstN` (fail this many requests with a transient +// 500 before succeeding - test tokenRequestMaxRetries) params. +// password grant additionally requires `username`/`password` +// form fields. Every non-standard request header (anything +// other than Authorization/Content-Type/Content-Length) is +// captured and echoed back via GET /debug/stats - test +// tokenRequestHeaders. +// GET /debug/stats JSON summary of every token request received so far — +// use this to confirm the gateway cached a token instead +// of calling the IdP on every request, and to confirm a +// refresh happened after expiry. +// POST /debug/reset Clears the request history (call between test flows). +// GET /healthz Liveness probe. +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// defaultTTLSeconds matches the doc comment above ("default 300"). Must stay +// comfortably above the oauth2-generator policy's own default expiryBuffer +// (30s) - every test that relies on the mock's default expires_in (i.e. +// doesn't pass its own ?ttl=) also relies on repeated calls within that +// window being served from cache, which expiryBuffer would otherwise defeat +// the moment this value gets close to (or below) 30s. +const defaultTTLSeconds = 300 + +// maxTokenRequestBytes bounds handleToken's form body - a slow-loris-style +// oversized request must not be able to tie up this mock's single process. +const maxTokenRequestBytes = 1 << 20 // 1 MiB + +var ( + validClientID = envOr("CLIENT_ID", "test-client") + validClientSecret = envOr("CLIENT_SECRET", "test-secret") + + // validUsername/validPassword are the resource-owner credentials + // accepted for grant_type=password (RFC 6749 Section 4.3). + validUsername = envOr("RESOURCE_OWNER_USERNAME", "resource-owner") + validPassword = envOr("RESOURCE_OWNER_PASSWORD", "hunter2") + + mu sync.Mutex + tokenSeq int + history []tokenRequestRecord + failCounter int // requests failed so far under the current failFirstN - see handleToken +) + +// tokenRequestRecord captures one /oauth2/token call for later inspection via +// GET /debug/stats — this is what lets a curl-driven test prove caching or +// refresh behavior without reading gateway logs. +type tokenRequestRecord struct { + Time time.Time `json:"time"` + ClientID string `json:"clientId"` + AuthStyle string `json:"authStyle"` // "basic" or "post" + Scope string `json:"scope,omitempty"` + Outcome string `json:"outcome"` // "issued", "invalid_client", "malformed", "server_error", "forced_failure" + Token string `json:"token,omitempty"` + Headers map[string]string `json:"headers,omitempty"` // non-standard request headers - see extractCustomHeaders +} + +// standardTokenRequestHeaders are excluded from the captured Headers map - +// they're either already represented elsewhere in tokenRequestRecord +// (Authorization -> AuthStyle) or are plain HTTP/transport mechanics with no +// test value. +var standardTokenRequestHeaders = map[string]bool{ + "Authorization": true, + "Content-Type": true, + "Content-Length": true, + "Accept-Encoding": true, + "User-Agent": true, + "Host": true, +} + +// extractCustomHeaders captures every header on a token request that isn't +// one of the standard/already-tracked ones above - this is how a test proves +// tokenRequestHeaders actually reached the token endpoint. +func extractCustomHeaders(r *http.Request) map[string]string { + captured := map[string]string{} + for name, values := range r.Header { + if standardTokenRequestHeaders[http.CanonicalHeaderKey(name)] || len(values) == 0 { + continue + } + captured[name] = values[0] + } + if len(captured) == 0 { + return nil + } + return captured +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// envOrDuration parses a positive duration from key, falling back to +// fallback if unset, empty, unparseable, or non-positive. +func envOrDuration(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return fallback + } + return d +} + +// maskSecret keeps only enough of a credential/header to correlate log lines +// without leaking the value itself (see GO-AUTH-003). +func maskSecret(s string) string { + if s == "" { + return "" + } + if len(s) <= 8 { + return "[MASKED]" + } + return s[:4] + "..." + s[len(s)-4:] +} + +// loggingMiddleware logs every inbound request (method, path, remote addr, +// masked Authorization header) so a manual test run has a full audit trail +// of what actually reached the mock IdP. +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("request: method=%s path=%s remote=%s authorization=%s", + r.Method, r.URL.Path, r.RemoteAddr, maskSecret(r.Header.Get("Authorization"))) + next.ServeHTTP(w, r) + }) +} + +func main() { + addr := envOr("ADDR", ":9601") + tlsCertFile := os.Getenv("TLS_CERT_FILE") + tlsKeyFile := os.Getenv("TLS_KEY_FILE") + + mux := http.NewServeMux() + mux.HandleFunc("POST /oauth2/token", handleToken) + mux.HandleFunc("GET /debug/stats", handleStats) + mux.HandleFunc("POST /debug/reset", handleReset) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + log.Printf("mock-oauth2-idp listening on %s (valid client: %s)", addr, validClientID) + + srv := &http.Server{ + Addr: addr, + Handler: loggingMiddleware(mux), + ReadTimeout: envOrDuration("READ_TIMEOUT", 10*time.Second), + WriteTimeout: envOrDuration("WRITE_TIMEOUT", 10*time.Second), + IdleTimeout: envOrDuration("IDLE_TIMEOUT", 60*time.Second), + MaxHeaderBytes: 1 << 20, + } + + // TLS_CERT_FILE/TLS_KEY_FILE (both required together) switch this mock + // to HTTPS - used to test the policy's tlsCaCert (trust a private + // CA) and tlsInsecureSkipVerify params, neither of which have any + // effect against a plain-HTTP token endpoint. See TESTING.md for how to + // generate a self-signed cert for this. + if tlsCertFile != "" && tlsKeyFile != "" { + log.Print("TLS enabled - token endpoint: https:///oauth2/token") + log.Fatal(srv.ListenAndServeTLS(tlsCertFile, tlsKeyFile)) + } + log.Print("token endpoint: http:///oauth2/token") + log.Fatal(srv.ListenAndServe()) +} + +func handleToken(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxTokenRequestBytes) + if err := r.ParseForm(); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_request", "failed to parse form body") + return + } + + // delayMs (query param or form field, like ttl) artificially delays this + // handler before doing anything else - simulates a slow/hung IdP to + // exercise the policy's tokenRequestTimeout. Applied first, before any + // validation, so it delays the response regardless of whether the + // request would otherwise succeed or fail. Cancelable via the request's + // context - once the caller (the gateway's own tokenRequestTimeout) + // gives up and disconnects, this returns immediately instead of running + // to completion and recording a stray, late entry into whatever test's + // debug history happens to be open several seconds later. + if v := r.FormValue("delayMs"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + select { + case <-time.After(time.Duration(parsed) * time.Millisecond): + case <-r.Context().Done(): + return + } + } + } + + clientID, clientSecret, authStyle, err := extractClientCredentials(r) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_client", err.Error()) + recordRequest(r, clientID, authStyle, r.PostForm.Get("scope"), "invalid_client", "") + return + } + + grantType := r.PostForm.Get("grant_type") + if grantType != "client_credentials" && grantType != "password" { + writeJSONError(w, http.StatusBadRequest, "unsupported_grant_type", "only client_credentials and password are supported by this mock") + recordRequest(r, clientID, authStyle, r.PostForm.Get("scope"), "invalid_client", "") + return + } + + // For the password grant, the resource owner's username/password are + // additional required fields alongside client authentication - checked + // against the same valid client_id/client_secret below, plus a fixed + // valid resource-owner pair (overridable via RESOURCE_OWNER_USERNAME / + // RESOURCE_OWNER_PASSWORD). + if grantType == "password" { + username := r.PostForm.Get("username") + password := r.PostForm.Get("password") + if username != validUsername || password != validPassword { + writeJSONError(w, http.StatusBadRequest, "invalid_grant", "resource owner credentials are invalid") + recordRequest(r, clientID, authStyle, r.PostForm.Get("scope"), "invalid_client", "") + return + } + } + + scope := r.PostForm.Get("scope") + + switch clientID { + case "broken-client": + recordRequest(r, clientID, authStyle, scope, "server_error", "") + http.Error(w, "internal server error (simulated IdP outage)", http.StatusInternalServerError) + return + + case "malformed-client": + // 200 OK but the body is missing access_token — exercises the + // policy's "successful fetch, malformed response" failure path. + recordRequest(r, clientID, authStyle, scope, "malformed", "") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token_type":"Bearer","expires_in":300}`)) + return + + case validClientID: + if clientSecret != validClientSecret { + writeJSONError(w, http.StatusBadRequest, "invalid_client", "client secret does not match") + recordRequest(r, clientID, authStyle, scope, "invalid_client", "") + return + } + // fall through to issue a token + + default: + writeJSONError(w, http.StatusBadRequest, "invalid_client", "unknown client_id") + recordRequest(r, clientID, authStyle, scope, "invalid_client", "") + return + } + + // failFirstN (query param or form field, like ttl) fails this many + // otherwise-valid requests with a transient 500 before letting one + // through - exercises the policy's tokenRequestMaxRetries. The counter + // is process-wide (reset via POST /debug/reset), not per-client, since + // a test only ever drives one client through this at a time. + if v := r.FormValue("failFirstN"); v != "" { + if failFirstN, err := strconv.Atoi(v); err == nil && failFirstN > 0 { + mu.Lock() + shouldFail := failCounter < failFirstN + if shouldFail { + failCounter++ + } + mu.Unlock() + if shouldFail { + recordRequest(r, clientID, authStyle, scope, "forced_failure", "") + http.Error(w, "internal server error (simulated transient failure)", http.StatusInternalServerError) + return + } + } + } + + // FormValue (not PostForm.Get) so `ttl` can be supplied either as a form + // field in the token request body, or as a query parameter appended to + // the configured tokenEndpoint (e.g. "...?ttl=2") — the latter is the + // only practical way to drive a short TTL through a real OAuth2 client + // library, since libraries generally don't expose a way to add an + // arbitrary extra body field per grant request. + ttl := defaultTTLSeconds + if v := r.FormValue("ttl"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + ttl = parsed + } + } + + mu.Lock() + tokenSeq++ + seq := tokenSeq + mu.Unlock() + + // The token value embeds a sequence number and issue time so a test can + // tell, just by comparing the string returned to the gateway's upstream + // call, whether a cached token was reused or a fresh one was minted. + token := fmt.Sprintf("mock-token-%d-issued-%d", seq, time.Now().UnixNano()) + recordRequest(r, clientID, authStyle, scope, "issued", token) + + resp := map[string]interface{}{ + "access_token": token, + "token_type": "Bearer", + } + // omitExpiresIn simulates an IdP that doesn't return expires_in at all - + // exercises the policy's defaultTokenTTL fallback. ttl still governs + // nothing about the response in that case; it's simply not sent. + if r.FormValue("omitExpiresIn") != "true" { + resp["expires_in"] = ttl + } + if scope != "" { + resp["scope"] = scope + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) +} + +// extractClientCredentials supports both RFC 6749 client authentication +// conventions: client_secret_basic (Authorization: Basic header) and +// client_secret_post (client_id/client_secret as form fields). +func extractClientCredentials(r *http.Request) (clientID, clientSecret, authStyle string, err error) { + if user, pass, ok := r.BasicAuth(); ok { + return user, pass, "basic", nil + } + + // r.BasicAuth() only succeeds for a well-formed "Basic " header; + // if an Authorization header is present but doesn't parse, surface that + // distinctly rather than silently falling through to POST-body auth. + if authHeader := r.Header.Get("Authorization"); authHeader != "" { + if strings.HasPrefix(authHeader, "Basic ") { + if _, decodeErr := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")); decodeErr != nil { + return "", "", "basic", fmt.Errorf("malformed Basic authorization header") + } + } + } + + clientID = r.PostForm.Get("client_id") + clientSecret = r.PostForm.Get("client_secret") + if clientID == "" { + return "", "", "post", fmt.Errorf("no client credentials presented (neither Basic auth nor client_id/client_secret form fields)") + } + return clientID, clientSecret, "post", nil +} + +func recordRequest(r *http.Request, clientID, authStyle, scope, outcome, token string) { + headers := extractCustomHeaders(r) + mu.Lock() + defer mu.Unlock() + history = append(history, tokenRequestRecord{ + Time: time.Now().UTC(), + ClientID: clientID, + AuthStyle: authStyle, + Scope: scope, + Outcome: outcome, + Token: token, + Headers: headers, + }) +} + +func handleStats(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tokenRequestCount": len(history), + "history": history, + }) +} + +func handleReset(w http.ResponseWriter, r *http.Request) { + mu.Lock() + history = nil + tokenSeq = 0 + failCounter = 0 + mu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + +func writeJSONError(w http.ResponseWriter, status int, errCode, description string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": errCode, + "error_description": description, + }) +}