From 39ffa85f9e3815fb6d245f491fd5a34789bb6a82 Mon Sep 17 00:00:00 2001 From: Trey Date: Thu, 21 May 2026 10:59:46 -0700 Subject: [PATCH 1/3] Admit obo in MCPExternalAuthConfig CRD enum Until this commit, the apiserver enum on MCPExternalAuthConfig.spec.type did not list "obo", so applying an obo-typed CR was rejected at admission before the previously-landed dispatch wiring could run. Implements changes for issue #5329: - Extend the CRD enum, CEL rules, and Go-level validateTypeConfigConsistency check on MCPExternalAuthConfigSpec to admit and validate the new "obo" type alongside the existing seven - Declare an opaque OBOConfig placeholder struct and spec.obo field; the inner schema lands in a follow-up - Document the two-tier validation pattern on the Validate() OBO arm: structural validation here, semantic validation behind the controllerutil.OBOValidate function-pointer hook at reconcile time - Regenerate operator-crds chart YAML, deepcopy, and public CRD reference - Add unit tests covering the OBO validation matrix and update existing OBO-typed test fixtures across operator and vMCP packages --- .../v1beta1/mcpexternalauthconfig_types.go | 57 ++++++++++++------ .../mcpexternalauthconfig_types_test.go | 60 +++++++++++++++++++ .../api/v1beta1/zz_generated.deepcopy.go | 20 +++++++ .../mcpexternalauthconfig_controller_test.go | 3 + .../mcpremoteproxy_controller_test.go | 1 + .../mcpserver_externalauth_test.go | 1 + .../virtualmcpserver_externalauth_test.go | 2 + .../pkg/controllerutil/tokenexchange_test.go | 1 + ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 28 ++++++++- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 28 ++++++++- docs/operator/crd-api.md | 21 ++++++- pkg/vmcp/auth/converters/obo_test.go | 3 + 12 files changed, 203 insertions(+), 22 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 467b445924..3aed0f53b2 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -61,12 +61,13 @@ type ExternalAuthType string // +kubebuilder:validation:XValidation:rule="self.type == 'embeddedAuthServer' ? has(self.embeddedAuthServer) : !has(self.embeddedAuthServer)",message="embeddedAuthServer configuration must be set if and only if type is 'embeddedAuthServer'" // +kubebuilder:validation:XValidation:rule="self.type == 'awsSts' ? has(self.awsSts) : !has(self.awsSts)",message="awsSts configuration must be set if and only if type is 'awsSts'" // +kubebuilder:validation:XValidation:rule="self.type == 'upstreamInject' ? has(self.upstreamInject) : !has(self.upstreamInject)",message="upstreamInject configuration must be set if and only if type is 'upstreamInject'" -// +kubebuilder:validation:XValidation:rule="self.type == 'unauthenticated' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) && !has(self.awsSts) && !has(self.upstreamInject)) : true",message="no configuration must be set when type is 'unauthenticated'" +// +kubebuilder:validation:XValidation:rule="self.type == 'obo' ? has(self.obo) : !has(self.obo)",message="obo configuration must be set if and only if type is 'obo'" +// +kubebuilder:validation:XValidation:rule="self.type == 'unauthenticated' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) : true",message="no configuration must be set when type is 'unauthenticated'" // //nolint:lll // CEL validation rules exceed line length limit type MCPExternalAuthConfigSpec struct { // Type is the type of external authentication to configure - // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject + // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject;obo // +kubebuilder:validation:Required Type ExternalAuthType `json:"type"` @@ -99,8 +100,24 @@ type MCPExternalAuthConfigSpec struct { // Only used when Type is "upstreamInject". // +optional UpstreamInject *UpstreamInjectSpec `json:"upstreamInject,omitempty"` + + // OBO configures On-Behalf-Of (OBO) authentication. + // Only used when Type is "obo". The inner schema is intentionally empty in + // this revision; sub-fields land in a follow-up. Setting this field on an + // upstream-only build will cause the MCPExternalAuthConfig to transition to + // status.conditions[Valid] = False with Reason: EnterpriseRequired. + // +optional + OBO *OBOConfig `json:"obo,omitempty"` } +// OBOConfig is a placeholder for On-Behalf-Of (OBO) external auth configuration. +// The inner schema is intentionally empty in this revision; sub-fields land in a +// follow-up. The struct exists today so that the CRD schema admits `spec.obo: {}` +// (matching the CEL rule "obo field must be set iff type is obo") and so that +// downstream tools that introspect the API surface can see the placeholder +// before the protocol-level fields land. +type OBOConfig struct{} + // TokenExchangeConfig holds configuration for RFC-8693 OAuth 2.0 Token Exchange. // This configuration is used to exchange incoming authentication tokens for tokens // that can be used with external services. @@ -1152,14 +1169,18 @@ func (r *MCPExternalAuthConfig) Validate() error { // No complex validation needed for these types return nil case ExternalAuthTypeOBO: - // OBO validation is delegated to the registered OBO handler at the - // controllerutil layer (via controllerutil.OBOValidate -> - // obo.ErrEnterpriseRequired in upstream-only builds), invoked from - // the reconcile loop. The CRD-level Validate() stays a no-op for OBO - // and exists only to keep the `exhaustive` linter happy now that - // ExternalAuthTypeOBO is defined. The CRD enum currently rejects - // "obo" at the apiserver layer, so this arm is unreachable in - // upstream-only builds. + // OBO uses a two-tier validation pattern. Structural validation + // (the OBO field is set iff Type is "obo") runs above in + // validateTypeConfigConsistency, mirrored by the CEL rule on the + // spec. Semantic validation (e.g., whether the cluster has an OBO + // handler registered) runs at reconcile time via the + // controllerutil.OBOValidate function-pointer hook: upstream-only + // builds return obo.ErrEnterpriseRequired, which the reconciler + // maps to status.conditions[Valid] = False / Reason: + // EnterpriseRequired. Out-of-tree builds that register a handler + // via controllerutil.RegisterOBOHandler short-circuit the sentinel + // and run their own protocol-level checks. Splitting the tiers + // this way keeps the upstream CRD schema stable across builds. return nil default: // Unknown type - should be caught by enum validation, but handle defensively @@ -1169,13 +1190,11 @@ func (r *MCPExternalAuthConfig) Validate() error { // validateTypeConfigConsistency validates that the correct config is set for the selected type. // This mirrors the CEL validation rules but provides defense-in-depth for stored objects. +// The per-type `if` shape is intentional: each row reads one-to-one against +// the corresponding CEL XValidation rule on the spec, so a reviewer can +// audit the structural-validation contract by skimming this function. // -// TODO(#5329): when OBOConfig is introduced in the CRD admission task, add a -// matching biconditional row here: -// -// (r.Spec.OBO == nil) == (r.Spec.Type == ExternalAuthTypeOBO) -// -// and update the unauthenticated check below to also assert !has(self.obo). +//nolint:gocyclo // intentionally one if per ExternalAuthType (parallels CEL rules) func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { // Check that each type has its corresponding config if (r.Spec.TokenExchange == nil) == (r.Spec.Type == ExternalAuthTypeTokenExchange) { @@ -1196,6 +1215,9 @@ func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { if (r.Spec.UpstreamInject == nil) == (r.Spec.Type == ExternalAuthTypeUpstreamInject) { return fmt.Errorf("upstreamInject configuration must be set if and only if type is 'upstreamInject'") } + if (r.Spec.OBO == nil) == (r.Spec.Type == ExternalAuthTypeOBO) { + return fmt.Errorf("obo configuration must be set if and only if type is 'obo'") + } // Check that unauthenticated has no config if r.Spec.Type == ExternalAuthTypeUnauthenticated { @@ -1204,7 +1226,8 @@ func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { r.Spec.BearerToken != nil || r.Spec.EmbeddedAuthServer != nil || r.Spec.AWSSts != nil || - r.Spec.UpstreamInject != nil { + r.Spec.UpstreamInject != nil || + r.Spec.OBO != nil { return fmt.Errorf("no configuration must be set when type is 'unauthenticated'") } } diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 73508bf0d7..8deb8bd5e4 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -304,6 +304,66 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { expectErr: true, errMsg: "oidcConfig must be set when type is 'oidc'", }, + { + name: "valid obo type with empty config", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-obo", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeOBO, + OBO: &OBOConfig{}, + }, + }, + expectErr: false, + }, + { + name: "invalid obo type with nil obo config", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-obo-missing", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeOBO, + OBO: nil, + }, + }, + expectErr: true, + errMsg: "obo configuration must be set if and only if type is 'obo'", + }, + { + name: "invalid obo config set on non-obo type", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-obo-on-tokenexchange", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{TokenURL: "https://example.com/token"}, + OBO: &OBOConfig{}, + }, + }, + expectErr: true, + errMsg: "obo configuration must be set if and only if type is 'obo'", + }, + { + name: "invalid obo config set on unauthenticated type", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-obo-on-unauth", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + OBO: &OBOConfig{}, + }, + }, + expectErr: true, + errMsg: "obo configuration must be set if and only if type is 'obo'", + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 465c82fc1e..07077737b5 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -766,6 +766,11 @@ func (in *MCPExternalAuthConfigSpec) DeepCopyInto(out *MCPExternalAuthConfigSpec *out = new(UpstreamInjectSpec) **out = **in } + if in.OBO != nil { + in, out := &in.OBO, &out.OBO + *out = new(OBOConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigSpec. @@ -2093,6 +2098,21 @@ func (in *OAuth2UpstreamConfig) DeepCopy() *OAuth2UpstreamConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OBOConfig) DeepCopyInto(out *OBOConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OBOConfig. +func (in *OBOConfig) DeepCopy() *OBOConfig { + if in == nil { + return nil + } + out := new(OBOConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCUpstreamConfig) DeepCopyInto(out *OIDCUpstreamConfig) { *out = *in diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go index 8f8a619198..194378fabb 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller_test.go @@ -1503,6 +1503,7 @@ func TestMCPExternalAuthConfigReconciler_OBO_DefaultHandler_SetsEnterpriseRequir }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, } @@ -1574,6 +1575,7 @@ func TestMCPExternalAuthConfigReconciler_OBO_ClearsStaleIdentitySynthesized(t *t }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, Status: mcpv1beta1.MCPExternalAuthConfigStatus{ Conditions: []metav1.Condition{ @@ -1722,6 +1724,7 @@ func TestMCPExternalAuthConfigReconciler_OBO_ErrorTriageInReconcile(t *testing.T }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go index 90cb758a48..69d715a4f3 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go @@ -700,6 +700,7 @@ func TestHandleExternalAuthConfig(t *testing.T) { }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, Status: mcpv1beta1.MCPExternalAuthConfigStatus{ Conditions: []metav1.Condition{{ diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go index f059b73452..6e763d0c23 100644 --- a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go @@ -567,6 +567,7 @@ func TestMCPServerReconciler_handleExternalAuthConfig_MirrorsInvalidCondition(t ObjectMeta: metav1.ObjectMeta{Name: authName, Namespace: namespace}, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, } if tt.sourceValid != nil { diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index 96551c73df..9a182d5820 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -868,6 +868,7 @@ func TestConvertBackendAuthConfigToVMCP_MirrorsInvalidExternalAuthConfig(t *test ObjectMeta: metav1.ObjectMeta{Name: "obo-source", Namespace: "default"}, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, Status: mcpv1beta1.MCPExternalAuthConfigStatus{ Conditions: []metav1.Condition{{ @@ -1845,6 +1846,7 @@ func TestGetExternalAuthConfigSecretEnvVar_OBO(t *testing.T) { }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, } diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go index 4a04a543ba..84a051082b 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go @@ -259,6 +259,7 @@ func TestAddExternalAuthConfigOptions_OBO(t *testing.T) { }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, } diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index a33c6b802d..2cf4635deb 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1044,6 +1044,14 @@ spec: - headerName - valueSecretRef type: object + obo: + description: |- + OBO configures On-Behalf-Of (OBO) authentication. + Only used when Type is "obo". The inner schema is intentionally empty in + this revision; sub-fields land in a follow-up. Setting this field on an + upstream-only build will cause the MCPExternalAuthConfig to transition to + status.conditions[Valid] = False with Reason: EnterpriseRequired. + type: object tokenExchange: description: |- TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange @@ -1123,6 +1131,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -1162,10 +1171,13 @@ spec: is 'upstreamInject' rule: 'self.type == ''upstreamInject'' ? has(self.upstreamInject) : !has(self.upstreamInject)' + - message: obo configuration must be set if and only if type is 'obo' + rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject)) : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) + : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig @@ -2306,6 +2318,14 @@ spec: - headerName - valueSecretRef type: object + obo: + description: |- + OBO configures On-Behalf-Of (OBO) authentication. + Only used when Type is "obo". The inner schema is intentionally empty in + this revision; sub-fields land in a follow-up. Setting this field on an + upstream-only build will cause the MCPExternalAuthConfig to transition to + status.conditions[Valid] = False with Reason: EnterpriseRequired. + type: object tokenExchange: description: |- TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange @@ -2385,6 +2405,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -2424,10 +2445,13 @@ spec: is 'upstreamInject' rule: 'self.type == ''upstreamInject'' ? has(self.upstreamInject) : !has(self.upstreamInject)' + - message: obo configuration must be set if and only if type is 'obo' + rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject)) : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) + : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 9298b59208..6f393bae23 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1047,6 +1047,14 @@ spec: - headerName - valueSecretRef type: object + obo: + description: |- + OBO configures On-Behalf-Of (OBO) authentication. + Only used when Type is "obo". The inner schema is intentionally empty in + this revision; sub-fields land in a follow-up. Setting this field on an + upstream-only build will cause the MCPExternalAuthConfig to transition to + status.conditions[Valid] = False with Reason: EnterpriseRequired. + type: object tokenExchange: description: |- TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange @@ -1126,6 +1134,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -1165,10 +1174,13 @@ spec: is 'upstreamInject' rule: 'self.type == ''upstreamInject'' ? has(self.upstreamInject) : !has(self.upstreamInject)' + - message: obo configuration must be set if and only if type is 'obo' + rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject)) : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) + : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig @@ -2309,6 +2321,14 @@ spec: - headerName - valueSecretRef type: object + obo: + description: |- + OBO configures On-Behalf-Of (OBO) authentication. + Only used when Type is "obo". The inner schema is intentionally empty in + this revision; sub-fields land in a follow-up. Setting this field on an + upstream-only build will cause the MCPExternalAuthConfig to transition to + status.conditions[Valid] = False with Reason: EnterpriseRequired. + type: object tokenExchange: description: |- TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange @@ -2388,6 +2408,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -2427,10 +2448,13 @@ spec: is 'upstreamInject' rule: 'self.type == ''upstreamInject'' ? has(self.upstreamInject) : !has(self.upstreamInject)' + - message: obo configuration must be set if and only if type is 'obo' + rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject)) : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) + : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index cbd9c54713..3c24b5cfc6 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1671,13 +1671,14 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject]
Required: \{\}
| +| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo]
Required: \{\}
| | `tokenExchange` _[api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | Optional: \{\}
| | `headerInjection` _[api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection
Only used when Type is "headerInjection" | | Optional: \{\}
| | `bearerToken` _[api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig)_ | BearerToken configures bearer token authentication
Only used when Type is "bearerToken" | | Optional: \{\}
| | `embeddedAuthServer` _[api.v1beta1.EmbeddedAuthServerConfig](#apiv1beta1embeddedauthserverconfig)_ | EmbeddedAuthServer configures an embedded OAuth2/OIDC authorization server
Only used when Type is "embeddedAuthServer" | | Optional: \{\}
| | `awsSts` _[api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig)_ | AWSSts configures AWS STS authentication with SigV4 request signing
Only used when Type is "awsSts" | | Optional: \{\}
| | `upstreamInject` _[api.v1beta1.UpstreamInjectSpec](#apiv1beta1upstreaminjectspec)_ | UpstreamInject configures upstream token injection for backend requests.
Only used when Type is "upstreamInject". | | Optional: \{\}
| +| `obo` _[api.v1beta1.OBOConfig](#apiv1beta1oboconfig)_ | OBO configures On-Behalf-Of (OBO) authentication.
Only used when Type is "obo". The inner schema is intentionally empty in
this revision; sub-fields land in a follow-up. Setting this field on an
upstream-only build will cause the MCPExternalAuthConfig to transition to
status.conditions[Valid] = False with Reason: EnterpriseRequired. | | Optional: \{\}
| #### api.v1beta1.MCPExternalAuthConfigStatus @@ -2747,6 +2748,24 @@ _Appears in:_ | `dcrConfig` _[api.v1beta1.DCRUpstreamConfig](#apiv1beta1dcrupstreamconfig)_ | DCRConfig enables RFC 7591 Dynamic Client Registration against the upstream
authorization server. When set, the client credentials are obtained at
runtime rather than being pre-provisioned, and ClientID must be left empty.
Mutually exclusive with ClientID. | | Optional: \{\}
| +#### api.v1beta1.OBOConfig + + + +OBOConfig is a placeholder for On-Behalf-Of (OBO) external auth configuration. +The inner schema is intentionally empty in this revision; sub-fields land in a +follow-up. The struct exists today so that the CRD schema admits `spec.obo: {}` +(matching the CEL rule "obo field must be set iff type is obo") and so that +downstream tools that introspect the API surface can see the placeholder +before the protocol-level fields land. + + + +_Appears in:_ +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) + + + #### api.v1beta1.OIDCUpstreamConfig diff --git a/pkg/vmcp/auth/converters/obo_test.go b/pkg/vmcp/auth/converters/obo_test.go index 403f03d764..daecaf0440 100644 --- a/pkg/vmcp/auth/converters/obo_test.go +++ b/pkg/vmcp/auth/converters/obo_test.go @@ -38,6 +38,7 @@ func TestOBOConverter_ConvertToStrategy(t *testing.T) { externalCfg: &mcpv1beta1.MCPExternalAuthConfig{ Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, }, }, @@ -69,6 +70,7 @@ func TestOBOConverter_ResolveSecrets(t *testing.T) { externalCfg: &mcpv1beta1.MCPExternalAuthConfig{ Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, }, strategy: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeOBO}, @@ -123,6 +125,7 @@ func TestDiscoverAndResolveAuth_OBO_SentinelSurvivesWrap(t *testing.T) { }, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ Type: mcpv1beta1.ExternalAuthTypeOBO, + OBO: &mcpv1beta1.OBOConfig{}, }, } From 62402593ba2d05410a1cc320823d290781699d8a Mon Sep 17 00:00:00 2001 From: Trey Date: Thu, 21 May 2026 11:05:55 -0700 Subject: [PATCH 2/3] Justify gocyclo suppression and document unauthenticated guard intent Address code-review feedback on the CRD admission change: - Expand the doc comment on validateTypeConfigConsistency so the //nolint:gocyclo directive meets the "confirmed false positive" bar from .claude/rules/go-style.md. The per-type if shape mirrors the CEL rules on MCPExternalAuthConfigSpec one-to-one; collapsing into a table-driven loop would obscure that correspondence. - Add an explanatory comment on the unauthenticated guard so it reads as shape-parity / belt-and-braces, not load-bearing validation. The per-type biconditionals above always intercept a populated config field first; the guard exists so a future contributor adding a type cannot forget to extend the invariant. --- .../api/v1beta1/mcpexternalauthconfig_types.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 3aed0f53b2..c0bf68e769 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -1194,7 +1194,16 @@ func (r *MCPExternalAuthConfig) Validate() error { // the corresponding CEL XValidation rule on the spec, so a reviewer can // audit the structural-validation contract by skimming this function. // -//nolint:gocyclo // intentionally one if per ExternalAuthType (parallels CEL rules) +// The gocyclo suppression is a confirmed false positive: every new +// ExternalAuthType adds one biconditional row and one disjunct to the +// unauthenticated guard, which the analyzer counts as branches even though +// the rows are syntactically uniform. Collapsing the rows into a +// table-driven loop would reduce the score but obscure the one-to-one +// correspondence with the CEL rules on MCPExternalAuthConfigSpec, which is +// the property reviewers rely on to audit the structural-validation +// contract. See issue #5329 for the broader discussion. +// +//nolint:gocyclo // one if per ExternalAuthType parallels the CEL rules on the spec; see doc comment func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { // Check that each type has its corresponding config if (r.Spec.TokenExchange == nil) == (r.Spec.Type == ExternalAuthTypeTokenExchange) { @@ -1219,7 +1228,10 @@ func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { return fmt.Errorf("obo configuration must be set if and only if type is 'obo'") } - // Check that unauthenticated has no config + // Belt-and-braces guard for `unauthenticated`: shape-parity with the per-type + // biconditionals above, which always intercept a populated config field first. + // Listed here so a future contributor adding a new type cannot forget to extend + // the "no configuration must be set" invariant. if r.Spec.Type == ExternalAuthTypeUnauthenticated { if r.Spec.TokenExchange != nil || r.Spec.HeaderInjection != nil || From 569f7e4a6c4fcbbf87b06da4935e71dfe7aa5a1e Mon Sep 17 00:00:00 2001 From: Trey Date: Thu, 21 May 2026 11:45:59 -0700 Subject: [PATCH 3/3] Polish OBO admission comments and test names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses stacklok/toolhive#5361 review comments: - LOW types.go (3283528614): F1 — say unauthenticated-guard OBO disjunct is unreachable; retained for forward-compat - LOW types.go (3283528635): F2 — replace stale "lines 44-49" ref in Validate() doc with structural reference - LOW types.go (3283528640): F3 — concrete OBOConfig rationale (compiles, admits spec.obo: {}); drop speculative downstream-consumer phrasing - LOW types.go (3283528649): F4 — note structural validation already ran in OBO Validate() arm comment - LOW types_test.go (3283528655): F5 — rename test #4 to note OBO biconditional intercepts first - LOW types_test.go (3283528662): F6 — move OBO test cases next to upstreamInject group - LOW types.go (3283528670): F7 — copy nolint:gocyclo rationale onto the directive line - LOW types.go (3283528675): F8 — extend Type field doc with obo build-requires-handler note (regenerated YAML + crd-api.md) - INFO types_test.go (3283528682): F9 — rename "empty config" to "placeholder OBOConfig" --- .../v1beta1/mcpexternalauthconfig_types.go | 54 ++++++++------- .../mcpexternalauthconfig_types_test.go | 65 ++++++++++--------- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 14 +++- ...e.stacklok.dev_mcpexternalauthconfigs.yaml | 14 +++- docs/operator/crd-api.md | 11 ++-- 5 files changed, 95 insertions(+), 63 deletions(-) diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index c0bf68e769..25bd4f603f 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -66,7 +66,11 @@ type ExternalAuthType string // //nolint:lll // CEL validation rules exceed line length limit type MCPExternalAuthConfigSpec struct { - // Type is the type of external authentication to configure + // Type is the type of external authentication to configure. + // When set to "obo", the cluster must run a build that has registered an + // OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds + // surface status.conditions[Valid] = False with Reason: EnterpriseRequired + // for obo-typed configs. // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject;obo // +kubebuilder:validation:Required Type ExternalAuthType `json:"type"` @@ -112,10 +116,11 @@ type MCPExternalAuthConfigSpec struct { // OBOConfig is a placeholder for On-Behalf-Of (OBO) external auth configuration. // The inner schema is intentionally empty in this revision; sub-fields land in a -// follow-up. The struct exists today so that the CRD schema admits `spec.obo: {}` -// (matching the CEL rule "obo field must be set iff type is obo") and so that -// downstream tools that introspect the API surface can see the placeholder -// before the protocol-level fields land. +// follow-up RFC. The struct exists so OBO *OBOConfig compiles and the CRD +// schema admits `spec.obo: {}` — the CEL rule "obo configuration must be set +// if and only if type is 'obo'" requires has(self.obo), which evaluates true +// for an empty object. Stored objects with `obo: {}` will round-trip cleanly +// when sub-fields land, because Go zero values fill in. type OBOConfig struct{} // TokenExchangeConfig holds configuration for RFC-8693 OAuth 2.0 Token Exchange. @@ -1142,7 +1147,8 @@ type MCPExternalAuthConfigList struct { // Validate performs validation on the MCPExternalAuthConfig spec. // This method is called by the controller during reconciliation. // -// Note: These validations provide defense-in-depth alongside CEL validation rules (lines 44-49). +// Note: These validations provide defense-in-depth alongside the +// +kubebuilder:validation:XValidation markers on MCPExternalAuthConfigSpec. // CEL catches issues at API admission time, but this method also validates stored objects // to catch any that bypassed CEL or were stored before CEL rules were added. func (r *MCPExternalAuthConfig) Validate() error { @@ -1169,18 +1175,20 @@ func (r *MCPExternalAuthConfig) Validate() error { // No complex validation needed for these types return nil case ExternalAuthTypeOBO: - // OBO uses a two-tier validation pattern. Structural validation - // (the OBO field is set iff Type is "obo") runs above in - // validateTypeConfigConsistency, mirrored by the CEL rule on the - // spec. Semantic validation (e.g., whether the cluster has an OBO - // handler registered) runs at reconcile time via the - // controllerutil.OBOValidate function-pointer hook: upstream-only - // builds return obo.ErrEnterpriseRequired, which the reconciler - // maps to status.conditions[Valid] = False / Reason: - // EnterpriseRequired. Out-of-tree builds that register a handler - // via controllerutil.RegisterOBOHandler short-circuit the sentinel - // and run their own protocol-level checks. Splitting the tiers - // this way keeps the upstream CRD schema stable across builds. + // Structural validation (the OBO field is set iff Type is "obo") + // has already run via r.validateTypeConfigConsistency() at the top + // of this method, so this arm is reached only when the structural + // invariant holds — and the matching CEL rule on the spec catches + // it at admission time. The remaining semantic validation + // (e.g., whether the cluster has an OBO handler registered) runs + // at reconcile time via the controllerutil.OBOValidate + // function-pointer hook: upstream-only builds return + // obo.ErrEnterpriseRequired, which the reconciler maps to + // status.conditions[Valid] = False / Reason: EnterpriseRequired. + // Out-of-tree builds that register a handler via + // controllerutil.RegisterOBOHandler short-circuit the sentinel and + // run their own protocol-level checks. Splitting the tiers this + // way keeps the upstream CRD schema stable across builds. return nil default: // Unknown type - should be caught by enum validation, but handle defensively @@ -1203,7 +1211,7 @@ func (r *MCPExternalAuthConfig) Validate() error { // the property reviewers rely on to audit the structural-validation // contract. See issue #5329 for the broader discussion. // -//nolint:gocyclo // one if per ExternalAuthType parallels the CEL rules on the spec; see doc comment +//nolint:gocyclo // one if per ExternalAuthType mirrors CEL rules; collapsing would obscure parity — see doc comment func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { // Check that each type has its corresponding config if (r.Spec.TokenExchange == nil) == (r.Spec.Type == ExternalAuthTypeTokenExchange) { @@ -1228,10 +1236,10 @@ func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { return fmt.Errorf("obo configuration must be set if and only if type is 'obo'") } - // Belt-and-braces guard for `unauthenticated`: shape-parity with the per-type - // biconditionals above, which always intercept a populated config field first. - // Listed here so a future contributor adding a new type cannot forget to extend - // the "no configuration must be set" invariant. + // Redundant with the per-type biconditionals above — each fires first for + // Type=Unauthenticated with any non-nil field — but retained as a single + // readable invariant so a contributor adding a new ExternalAuthType extends + // the "no configuration must be set" check here too. if r.Spec.Type == ExternalAuthTypeUnauthenticated { if r.Spec.TokenExchange != nil || r.Spec.HeaderInjection != nil || diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index 8deb8bd5e4..6b5a528eae 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -276,36 +276,7 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { errMsg: "upstreamInject requires a non-empty providerName", }, { - name: "invalid OIDC provider with oauth2Config instead", - config: &MCPExternalAuthConfig{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-oidc-wrong-config", - Namespace: "default", - }, - Spec: MCPExternalAuthConfigSpec{ - Type: ExternalAuthTypeEmbeddedAuthServer, - EmbeddedAuthServer: &EmbeddedAuthServerConfig{ - Issuer: "https://auth.example.com", - UpstreamProviders: []UpstreamProviderConfig{ - { - Name: "github", - Type: UpstreamProviderTypeOIDC, - OAuth2Config: &OAuth2UpstreamConfig{ - AuthorizationEndpoint: "https://github.com/authorize", - TokenEndpoint: "https://github.com/token", - ClientID: "client-id", - UserInfo: &UserInfoConfig{EndpointURL: "https://github.com/userinfo"}, - }, - }, - }, - }, - }, - }, - expectErr: true, - errMsg: "oidcConfig must be set when type is 'oidc'", - }, - { - name: "valid obo type with empty config", + name: "valid obo type with placeholder OBOConfig", config: &MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-obo", @@ -350,7 +321,10 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { errMsg: "obo configuration must be set if and only if type is 'obo'", }, { - name: "invalid obo config set on unauthenticated type", + // Also intentional shape-parity coverage for the unauthenticated + // guard's OBO != nil disjunct, even though the OBO biconditional + // above intercepts first for this input. + name: "invalid obo config on unauthenticated type (obo biconditional intercepts first)", config: &MCPExternalAuthConfig{ ObjectMeta: metav1.ObjectMeta{ Name: "test-obo-on-unauth", @@ -364,6 +338,35 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { expectErr: true, errMsg: "obo configuration must be set if and only if type is 'obo'", }, + { + name: "invalid OIDC provider with oauth2Config instead", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-oidc-wrong-config", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + UpstreamProviders: []UpstreamProviderConfig{ + { + Name: "github", + Type: UpstreamProviderTypeOIDC, + OAuth2Config: &OAuth2UpstreamConfig{ + AuthorizationEndpoint: "https://github.com/authorize", + TokenEndpoint: "https://github.com/token", + ClientID: "client-id", + UserInfo: &UserInfoConfig{EndpointURL: "https://github.com/userinfo"}, + }, + }, + }, + }, + }, + }, + expectErr: true, + errMsg: "oidcConfig must be set when type is 'oidc'", + }, } for _, tt := range tests { diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 2cf4635deb..61b2c43c46 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1122,7 +1122,12 @@ spec: - tokenUrl type: object type: - description: Type is the type of external authentication to configure + description: |- + Type is the type of external authentication to configure. + When set to "obo", the cluster must run a build that has registered an + OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds + surface status.conditions[Valid] = False with Reason: EnterpriseRequired + for obo-typed configs. enum: - tokenExchange - headerInjection @@ -2396,7 +2401,12 @@ spec: - tokenUrl type: object type: - description: Type is the type of external authentication to configure + description: |- + Type is the type of external authentication to configure. + When set to "obo", the cluster must run a build that has registered an + OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds + surface status.conditions[Valid] = False with Reason: EnterpriseRequired + for obo-typed configs. enum: - tokenExchange - headerInjection diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 6f393bae23..d657a82b07 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1125,7 +1125,12 @@ spec: - tokenUrl type: object type: - description: Type is the type of external authentication to configure + description: |- + Type is the type of external authentication to configure. + When set to "obo", the cluster must run a build that has registered an + OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds + surface status.conditions[Valid] = False with Reason: EnterpriseRequired + for obo-typed configs. enum: - tokenExchange - headerInjection @@ -2399,7 +2404,12 @@ spec: - tokenUrl type: object type: - description: Type is the type of external authentication to configure + description: |- + Type is the type of external authentication to configure. + When set to "obo", the cluster must run a build that has registered an + OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds + surface status.conditions[Valid] = False with Reason: EnterpriseRequired + for obo-typed configs. enum: - tokenExchange - headerInjection diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 3c24b5cfc6..b8f04ff351 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1671,7 +1671,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo]
Required: \{\}
| +| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure.
When set to "obo", the cluster must run a build that has registered an
OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds
surface status.conditions[Valid] = False with Reason: EnterpriseRequired
for obo-typed configs. | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo]
Required: \{\}
| | `tokenExchange` _[api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | Optional: \{\}
| | `headerInjection` _[api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection
Only used when Type is "headerInjection" | | Optional: \{\}
| | `bearerToken` _[api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig)_ | BearerToken configures bearer token authentication
Only used when Type is "bearerToken" | | Optional: \{\}
| @@ -2754,10 +2754,11 @@ _Appears in:_ OBOConfig is a placeholder for On-Behalf-Of (OBO) external auth configuration. The inner schema is intentionally empty in this revision; sub-fields land in a -follow-up. The struct exists today so that the CRD schema admits `spec.obo: {}` -(matching the CEL rule "obo field must be set iff type is obo") and so that -downstream tools that introspect the API surface can see the placeholder -before the protocol-level fields land. +follow-up RFC. The struct exists so OBO *OBOConfig compiles and the CRD +schema admits `spec.obo: {}` — the CEL rule "obo configuration must be set +if and only if type is 'obo'" requires has(self.obo), which evaluates true +for an empty object. Stored objects with `obo: {}` will round-trip cleanly +when sub-fields land, because Go zero values fill in.