diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 467b445924..25bd4f603f 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -61,12 +61,17 @@ 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 + // 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"` @@ -99,8 +104,25 @@ 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 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. // This configuration is used to exchange incoming authentication tokens for tokens // that can be used with external services. @@ -1125,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 { @@ -1152,14 +1175,20 @@ 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. + // 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 @@ -1169,13 +1198,20 @@ 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: +// 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. // -// (r.Spec.OBO == nil) == (r.Spec.Type == ExternalAuthTypeOBO) -// -// and update the unauthenticated check below to also assert !has(self.obo). +//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) { @@ -1196,15 +1232,22 @@ 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 + // 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 || 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..6b5a528eae 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -275,6 +275,69 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { expectErr: true, errMsg: "upstreamInject requires a non-empty providerName", }, + { + name: "valid obo type with placeholder OBOConfig", + 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'", + }, + { + // 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", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeUnauthenticated, + OBO: &OBOConfig{}, + }, + }, + expectErr: true, + errMsg: "obo configuration must be set if and only if type is 'obo'", + }, { name: "invalid OIDC provider with oauth2Config instead", config: &MCPExternalAuthConfig{ 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..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 @@ -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 @@ -1114,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 @@ -1123,6 +1136,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -1162,10 +1176,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 +2323,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 @@ -2376,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 @@ -2385,6 +2415,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -2424,10 +2455,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..d657a82b07 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 @@ -1117,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 @@ -1126,6 +1139,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -1165,10 +1179,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 +2326,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 @@ -2379,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 @@ -2388,6 +2418,7 @@ spec: - embeddedAuthServer - awsSts - upstreamInject + - obo type: string upstreamInject: description: |- @@ -2427,10 +2458,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..b8f04ff351 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.
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: \{\}
| | `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,25 @@ _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 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. + + + +_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{}, }, }