diff --git a/cmd/thv-operator/api/v1alpha1/types.go b/cmd/thv-operator/api/v1alpha1/types.go index ca88b5c924..933c3df2ad 100644 --- a/cmd/thv-operator/api/v1alpha1/types.go +++ b/cmd/thv-operator/api/v1alpha1/types.go @@ -126,6 +126,35 @@ type MCPOIDCConfigList struct { Items []MCPOIDCConfig `json:"items"` } +// ─── MCPAuthzConfig ────────────────────────────────────────────────────────── + +//+kubebuilder:object:root=true +//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1" +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=authzcfg,categories=toolhive +//+kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +//+kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` +//+kubebuilder:printcolumn:name="References",type=integer,JSONPath=`.status.referenceCount` +//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPAuthzConfig is the deprecated v1alpha1 version of the MCPAuthzConfig resource. +type MCPAuthzConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec v1beta1.MCPAuthzConfigSpec `json:"spec,omitempty"` + Status v1beta1.MCPAuthzConfigStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// MCPAuthzConfigList contains a list of MCPAuthzConfig. +type MCPAuthzConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPAuthzConfig `json:"items"` +} + // ─── MCPRegistry ───────────────────────────────────────────────────────────── //+kubebuilder:object:root=true @@ -397,6 +426,7 @@ type VirtualMCPServerList struct { func init() { SchemeBuilder.Register( &EmbeddingServer{}, &EmbeddingServerList{}, + &MCPAuthzConfig{}, &MCPAuthzConfigList{}, &MCPExternalAuthConfig{}, &MCPExternalAuthConfigList{}, &MCPGroup{}, &MCPGroupList{}, &MCPOIDCConfig{}, &MCPOIDCConfigList{}, diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index 8434da178f..de21e55fb5 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -83,6 +83,65 @@ func (in *EmbeddingServerList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfig) DeepCopyInto(out *MCPAuthzConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfig. +func (in *MCPAuthzConfig) DeepCopy() *MCPAuthzConfig { + if in == nil { + return nil + } + out := new(MCPAuthzConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPAuthzConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfigList) DeepCopyInto(out *MCPAuthzConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPAuthzConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfigList. +func (in *MCPAuthzConfigList) DeepCopy() *MCPAuthzConfigList { + if in == nil { + return nil + } + out := new(MCPAuthzConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPAuthzConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { *out = *in diff --git a/cmd/thv-operator/api/v1beta1/mcpauthzconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpauthzconfig_types.go new file mode 100644 index 0000000000..ae1cec20a2 --- /dev/null +++ b/cmd/thv-operator/api/v1beta1/mcpauthzconfig_types.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1beta1 + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// Condition type and reasons for MCPAuthzConfig status (RFC-0023) +const ( + // ConditionTypeAuthzConfigValid indicates whether the MCPAuthzConfig configuration is valid + ConditionTypeAuthzConfigValid = ConditionTypeValid + + // ConditionReasonAuthzConfigValid indicates spec validation passed + ConditionReasonAuthzConfigValid = "ConfigValid" + + // ConditionReasonAuthzConfigInvalid indicates spec validation failed + ConditionReasonAuthzConfigInvalid = "ConfigInvalid" +) + +// MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig. +// MCPAuthzConfig resources are namespace-scoped and can only be referenced by +// MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace. +type MCPAuthzConfigSpec struct { + // Type identifies the authorizer backend (e.g., "cedarv1", "httpv1"). + // Must match a registered authorizer type in the factory registry. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Type string `json:"type"` + + // Config contains the backend-specific authorization configuration. + // The structure depends on the Type field: + // - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string) + // - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string) + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + Config runtime.RawExtension `json:"config"` +} + +// MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig +type MCPAuthzConfigStatus struct { + // Conditions represent the latest available observations of the MCPAuthzConfig's state + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the most recent generation observed for this MCPAuthzConfig. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // ConfigHash is a hash of the current configuration for change detection + // +optional + ConfigHash string `json:"configHash,omitempty"` + + // ReferenceCount is the number of workloads referencing this config. + // +optional + ReferenceCount int32 `json:"referenceCount,omitempty"` + + // ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig. + // Each entry identifies the workload by kind and name. + // +listType=map + // +listMapKey=name + // +optional + ReferencingWorkloads []WorkloadReference `json:"referencingWorkloads,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:storageversion +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true +// +kubebuilder:resource:shortName=authzcfg,categories=toolhive +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status` +// +kubebuilder:printcolumn:name="References",type=integer,JSONPath=`.status.referenceCount` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// MCPAuthzConfig is the Schema for the mcpauthzconfigs API. +// MCPAuthzConfig resources are namespace-scoped and can only be referenced by +// MCPServer, MCPRemoteProxy, or VirtualMCPServer resources within the same namespace. +// Cross-namespace references are not supported for security and isolation reasons. +type MCPAuthzConfig struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec MCPAuthzConfigSpec `json:"spec,omitempty"` + Status MCPAuthzConfigStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// MCPAuthzConfigList contains a list of MCPAuthzConfig +type MCPAuthzConfigList struct { + metav1.TypeMeta `json:",inline"` // nolint:revive + metav1.ListMeta `json:"metadata,omitempty"` + Items []MCPAuthzConfig `json:"items"` +} + +// MCPAuthzConfigReference references a shared MCPAuthzConfig resource. +// The referenced MCPAuthzConfig must be in the same namespace as the referencing workload. +type MCPAuthzConfigReference struct { + // Name is the name of the MCPAuthzConfig resource in the same namespace. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// Validate performs structural validation on the MCPAuthzConfig spec. +// This method is called by the controller during reconciliation. +// +// Note: This provides defense-in-depth alongside CEL validation rules. 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. +// +// Backend-specific validation (delegating to the authorizer factory registry) lives +// in the controller rather than here, because the API types package must not import +// the authorizer backends. +func (r *MCPAuthzConfig) Validate() error { + if r.Spec.Type == "" { + return fmt.Errorf("type must not be empty") + } + if len(r.Spec.Config.Raw) == 0 { + return fmt.Errorf("config must not be empty") + } + return nil +} + +func init() { + SchemeBuilder.Register(&MCPAuthzConfig{}, &MCPAuthzConfigList{}) +} diff --git a/cmd/thv-operator/api/v1beta1/mcpauthzconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpauthzconfig_types_test.go new file mode 100644 index 0000000000..824b7028c4 --- /dev/null +++ b/cmd/thv-operator/api/v1beta1/mcpauthzconfig_types_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package v1beta1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +func TestMCPAuthzConfig_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec MCPAuthzConfigSpec + expectError bool + }{ + { + name: "valid spec passes", + spec: MCPAuthzConfigSpec{ + Type: "cedarv1", + Config: runtime.RawExtension{Raw: []byte(`{"policies":[]}`)}, + }, + expectError: false, + }, + { + name: "empty type fails", + spec: MCPAuthzConfigSpec{ + Type: "", + Config: runtime.RawExtension{Raw: []byte(`{}`)}, + }, + expectError: true, + }, + { + name: "empty config raw fails", + spec: MCPAuthzConfigSpec{ + Type: "cedarv1", + Config: runtime.RawExtension{Raw: []byte{}}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := &MCPAuthzConfig{Spec: tt.spec} + err := cfg.Validate() + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go index 9cf36c0de6..77cb1689b7 100644 --- a/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go @@ -36,6 +36,10 @@ type HeaderFromSecret struct { } // MCPRemoteProxySpec defines the desired state of MCPRemoteProxy +// +// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig" +// +//nolint:lll // CEL validation rules exceed line length limit type MCPRemoteProxySpec struct { // RemoteURL is the URL of the remote MCP server to proxy // +kubebuilder:validation:Required @@ -77,10 +81,18 @@ type MCPRemoteProxySpec struct { // +optional HeaderForward *HeaderForwardConfig `json:"headerForward,omitempty"` - // AuthzConfig defines authorization policy configuration for the proxy + // AuthzConfig defines authorization policy configuration for the proxy. + // Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + // AuthzConfig and AuthzConfigRef are mutually exclusive. // +optional AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"` + // AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + // The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy. + // Mutually exclusive with authzConfig. + // +optional + AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"` + // Audit defines audit logging configuration for the proxy // +optional Audit *AuditConfig `json:"audit,omitempty"` diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go index 66c12b9149..d79aaf64d0 100644 --- a/cmd/thv-operator/api/v1beta1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go @@ -200,6 +200,7 @@ const SessionStorageProviderRedis = "redis" // MCPServerSpec defines the desired state of MCPServer // +// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig" // +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="rateLimiting requires sessionStorage with provider 'redis'" // +kubebuilder:validation:XValidation:rule="!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="rateLimiting.perUser requires authentication (oidcConfigRef or externalAuthConfigRef)" // +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, !has(t.perUser)) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="per-tool perUser rate limiting requires authentication (oidcConfigRef or externalAuthConfigRef)" @@ -293,10 +294,18 @@ type MCPServerSpec struct { // +optional OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"` - // AuthzConfig defines authorization policy configuration for the MCP server + // AuthzConfig defines authorization policy configuration for the MCP server. + // Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + // AuthzConfig and AuthzConfigRef are mutually exclusive. // +optional AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"` + // AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + // The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer. + // Mutually exclusive with authzConfig. + // +optional + AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"` + // Audit defines audit logging configuration for the MCP server // +optional Audit *AuditConfig `json:"audit,omitempty"` diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index b9a8731031..87f38c5042 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -160,6 +160,7 @@ type EmbeddingServerRef struct { // IncomingAuthConfig configures authentication for clients connecting to the Virtual MCP server // // +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? has(self.oidcConfigRef) : true",message="spec.incomingAuth.oidcConfigRef is required when type is oidc" +// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig" // //nolint:lll // CEL validation rules exceed line length limit type IncomingAuthConfig struct { @@ -176,10 +177,18 @@ type IncomingAuthConfig struct { // +optional OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"` - // AuthzConfig defines authorization policy configuration - // Reuses MCPServer authz patterns + // AuthzConfig defines authorization policy configuration. + // Reuses MCPServer authz patterns. + // Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + // AuthzConfig and AuthzConfigRef are mutually exclusive. // +optional AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"` + + // AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + // The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer. + // Mutually exclusive with authzConfig. + // +optional + AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"` } // OutgoingAuthConfig configures authentication from Virtual MCP to backend MCPServers diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 07077737b5..60730896b5 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -597,6 +597,11 @@ func (in *IncomingAuthConfig) DeepCopyInto(out *IncomingAuthConfig) { *out = new(AuthzConfigRef) (*in).DeepCopyInto(*out) } + if in.AuthzConfigRef != nil { + in, out := &in.AuthzConfigRef, &out.AuthzConfigRef + *out = new(MCPAuthzConfigReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IncomingAuthConfig. @@ -674,6 +679,123 @@ func (in *KubernetesServiceAccountOIDCConfig) DeepCopy() *KubernetesServiceAccou return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfig) DeepCopyInto(out *MCPAuthzConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfig. +func (in *MCPAuthzConfig) DeepCopy() *MCPAuthzConfig { + if in == nil { + return nil + } + out := new(MCPAuthzConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPAuthzConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfigList) DeepCopyInto(out *MCPAuthzConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]MCPAuthzConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfigList. +func (in *MCPAuthzConfigList) DeepCopy() *MCPAuthzConfigList { + if in == nil { + return nil + } + out := new(MCPAuthzConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *MCPAuthzConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfigReference) DeepCopyInto(out *MCPAuthzConfigReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfigReference. +func (in *MCPAuthzConfigReference) DeepCopy() *MCPAuthzConfigReference { + if in == nil { + return nil + } + out := new(MCPAuthzConfigReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfigSpec) DeepCopyInto(out *MCPAuthzConfigSpec) { + *out = *in + in.Config.DeepCopyInto(&out.Config) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfigSpec. +func (in *MCPAuthzConfigSpec) DeepCopy() *MCPAuthzConfigSpec { + if in == nil { + return nil + } + out := new(MCPAuthzConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPAuthzConfigStatus) DeepCopyInto(out *MCPAuthzConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ReferencingWorkloads != nil { + in, out := &in.ReferencingWorkloads, &out.ReferencingWorkloads + *out = make([]WorkloadReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthzConfigStatus. +func (in *MCPAuthzConfigStatus) DeepCopy() *MCPAuthzConfigStatus { + if in == nil { + return nil + } + out := new(MCPAuthzConfigStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { *out = *in @@ -1279,6 +1401,11 @@ func (in *MCPRemoteProxySpec) DeepCopyInto(out *MCPRemoteProxySpec) { *out = new(AuthzConfigRef) (*in).DeepCopyInto(*out) } + if in.AuthzConfigRef != nil { + in, out := &in.AuthzConfigRef, &out.AuthzConfigRef + *out = new(MCPAuthzConfigReference) + **out = **in + } if in.Audit != nil { in, out := &in.Audit, &out.Audit *out = new(AuditConfig) @@ -1573,6 +1700,11 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = new(AuthzConfigRef) (*in).DeepCopyInto(*out) } + if in.AuthzConfigRef != nil { + in, out := &in.AuthzConfigRef, &out.AuthzConfigRef + *out = new(MCPAuthzConfigReference) + **out = **in + } if in.Audit != nil { in, out := &in.Audit, &out.Audit *out = new(AuditConfig) diff --git a/cmd/thv-operator/app/app.go b/cmd/thv-operator/app/app.go index 14614a06ce..479606b0c6 100644 --- a/cmd/thv-operator/app/app.go +++ b/cmd/thv-operator/app/app.go @@ -36,6 +36,11 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/controllers" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/imagepullsecrets" + // Import authorizer backends so they register with the factory registry. + // Placed in the binary entrypoint (not the controller) to keep the + // MCPAuthzConfig controller backend-agnostic. + _ "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" + _ "github.com/stacklok/toolhive/pkg/authz/authorizers/http" "github.com/stacklok/toolhive/pkg/operator/telemetry" ) @@ -312,6 +317,14 @@ func setupServerControllers(mgr ctrl.Manager, imagePullSecretsDefaults imagepull return fmt.Errorf("unable to create controller MCPOIDCConfig: %w", err) } + // Set up MCPAuthzConfig controller + if err := (&controllers.MCPAuthzConfigReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create controller MCPAuthzConfig: %w", err) + } + // Set up MCPTelemetryConfig controller if err := (&controllers.MCPTelemetryConfigReconciler{ Client: mgr.GetClient(), diff --git a/cmd/thv-operator/controllers/mcpauthzconfig_controller.go b/cmd/thv-operator/controllers/mcpauthzconfig_controller.go new file mode 100644 index 0000000000..4b69d2df0b --- /dev/null +++ b/cmd/thv-operator/controllers/mcpauthzconfig_controller.go @@ -0,0 +1,457 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/pkg/authz/authorizers" +) + +const ( + // AuthzConfigFinalizerName is the name of the finalizer for MCPAuthzConfig + AuthzConfigFinalizerName = "mcpauthzconfig.toolhive.stacklok.dev/finalizer" + + // authzConfigRequeueDelay is the delay before requeuing after adding a finalizer + authzConfigRequeueDelay = 500 * time.Millisecond + + // authzConfigVersion is the default version for reconstructed authz configs + authzConfigVersion = "1.0" +) + +// MCPAuthzConfigReconciler reconciles a MCPAuthzConfig object. +// +// This controller manages the lifecycle of MCPAuthzConfig resources: validation +// via the authorizer factory registry, config hash computation, finalizer management, +// reference tracking, and deletion protection when workloads reference this config. +type MCPAuthzConfigReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpauthzconfigs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpauthzconfigs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpauthzconfigs/finalizers,verbs=update +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpservers,verbs=get;list;watch +// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies,verbs=get;list;watch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *MCPAuthzConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the MCPAuthzConfig instance + authzConfig := &mcpv1beta1.MCPAuthzConfig{} + err := r.Get(ctx, req.NamespacedName, authzConfig) + if err != nil { + if errors.IsNotFound(err) { + logger.Info("MCPAuthzConfig resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get MCPAuthzConfig") + return ctrl.Result{}, err + } + + // Check if the MCPAuthzConfig is being deleted + if !authzConfig.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, authzConfig) + } + + // Add finalizer if it doesn't exist + if !controllerutil.ContainsFinalizer(authzConfig, AuthzConfigFinalizerName) { + controllerutil.AddFinalizer(authzConfig, AuthzConfigFinalizerName) + if err := r.Update(ctx, authzConfig); err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: authzConfigRequeueDelay}, nil + } + + // Validate the authz configuration: structural checks via the type's Validate() + // method, then backend-specific validation via the authorizer factory registry. + if err := r.validateAuthzConfig(authzConfig); err != nil { + logger.Error(err, "MCPAuthzConfig spec validation failed") + meta.SetStatusCondition(&authzConfig.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeAuthzConfigValid, + Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonAuthzConfigInvalid, + Message: err.Error(), + ObservedGeneration: authzConfig.Generation, + }) + if updateErr := r.Status().Update(ctx, authzConfig); updateErr != nil { + logger.Error(updateErr, "Failed to update status after validation error") + } + return ctrl.Result{}, nil // Don't requeue on validation errors - user must fix spec + } + + // Validation succeeded - set Valid=True condition + conditionChanged := meta.SetStatusCondition(&authzConfig.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeAuthzConfigValid, + Status: metav1.ConditionTrue, + Reason: mcpv1beta1.ConditionReasonAuthzConfigValid, + Message: "Spec validation passed", + ObservedGeneration: authzConfig.Generation, + }) + + // Calculate the hash of the current configuration + configHash := ctrlutil.CalculateConfigHash(authzConfig.Spec) + + // Check if the hash has changed + hashChanged := authzConfig.Status.ConfigHash != configHash + if hashChanged { + logger.Info("MCPAuthzConfig configuration changed", + "oldHash", authzConfig.Status.ConfigHash, + "newHash", configHash) + + authzConfig.Status.ConfigHash = configHash + authzConfig.Status.ObservedGeneration = authzConfig.Generation + + if err := r.Status().Update(ctx, authzConfig); err != nil { + logger.Error(err, "Failed to update MCPAuthzConfig status") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Refresh ReferencingWorkloads list + referencingWorkloads, err := r.findReferencingWorkloads(ctx, authzConfig) + if err != nil { + logger.Error(err, "Failed to find referencing workloads") + } else if !ctrlutil.WorkloadRefsEqual(authzConfig.Status.ReferencingWorkloads, referencingWorkloads) || + authzConfig.Status.ReferenceCount != workloadReferenceCount(referencingWorkloads) { + authzConfig.Status.ReferencingWorkloads = referencingWorkloads + authzConfig.Status.ReferenceCount = workloadReferenceCount(referencingWorkloads) + conditionChanged = true + } + + // Update condition if it changed (even without hash change) + if conditionChanged { + if err := r.Status().Update(ctx, authzConfig); err != nil { + logger.Error(err, "Failed to update MCPAuthzConfig status after condition change") + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// validateAuthzConfig validates the MCPAuthzConfig. It first runs the structural +// validation on the type (Validate()), then reconstructs the full authorizer config +// and delegates backend-specific validation to the factory's ValidateConfig method. +// +// Backend validation lives here rather than as a Validate() method on the type because +// it requires the authorizer factory registry — an external dependency that the API +// types package must not import. +func (*MCPAuthzConfigReconciler) validateAuthzConfig(authzConfig *mcpv1beta1.MCPAuthzConfig) error { + if err := authzConfig.Validate(); err != nil { + return err + } + + fullConfigJSON, err := BuildFullAuthzConfigJSON(authzConfig.Spec) + if err != nil { + return err + } + + // Parse and validate via the authorizer factory + var cfg authzConfigEnvelope + if err := json.Unmarshal(fullConfigJSON, &cfg); err != nil { + return fmt.Errorf("failed to parse reconstructed authz config: %w", err) + } + if cfg.Version == "" || cfg.Type == "" { + return fmt.Errorf("reconstructed config missing version or type") + } + + factory := authorizers.GetFactory(cfg.Type) + if factory == nil { + return fmt.Errorf("unsupported authorizer type: %s (registered types: %v)", + cfg.Type, authorizers.RegisteredTypes()) + } + + return factory.ValidateConfig(fullConfigJSON) +} + +// authzConfigEnvelope is a minimal struct for extracting version and type from reconstructed JSON. +type authzConfigEnvelope struct { + Version string `json:"version"` + Type string `json:"type"` +} + +// BuildFullAuthzConfigJSON reconstructs the full authorizer config JSON from a +// MCPAuthzConfig spec. The result is the same format accepted by authorizers.Config +// and used in ConfigMaps: {"version": "1.0", "type": "", "": {}}. +func BuildFullAuthzConfigJSON(spec mcpv1beta1.MCPAuthzConfigSpec) ([]byte, error) { + factory := authorizers.GetFactory(spec.Type) + if factory == nil { + return nil, fmt.Errorf("unsupported authorizer type: %s (registered types: %v)", + spec.Type, authorizers.RegisteredTypes()) + } + + configKey := factory.ConfigKey() + + if len(spec.Config.Raw) == 0 { + return nil, fmt.Errorf("config field is empty") + } + + versionJSON, err := marshalJSONString(authzConfigVersion) + if err != nil { + return nil, fmt.Errorf("failed to marshal version: %w", err) + } + typeJSON, err := marshalJSONString(spec.Type) + if err != nil { + return nil, fmt.Errorf("failed to marshal type: %w", err) + } + + fullConfig := map[string]json.RawMessage{ + "version": versionJSON, + "type": typeJSON, + configKey: spec.Config.Raw, + } + + result, err := json.Marshal(fullConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal full authz config: %w", err) + } + return result, nil +} + +// marshalJSONString marshals a string value to JSON, returning an error instead of panicking. +func marshalJSONString(v string) (json.RawMessage, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to marshal %q: %w", v, err) + } + return b, nil +} + +// handleDeletion handles the deletion of a MCPAuthzConfig. +// Blocks deletion while workload resources reference this config by keeping the +// finalizer and requeueing. Once all references are removed, the finalizer is removed +// and the resource can be garbage collected. +func (r *MCPAuthzConfigReconciler) handleDeletion( + ctx context.Context, + authzConfig *mcpv1beta1.MCPAuthzConfig, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if controllerutil.ContainsFinalizer(authzConfig, AuthzConfigFinalizerName) { + // Check if any workloads still reference this config + referencingWorkloads, err := r.findReferencingWorkloads(ctx, authzConfig) + if err != nil { + logger.Error(err, "Failed to check referencing workloads during deletion") + return ctrl.Result{}, err + } + + if len(referencingWorkloads) > 0 { + logger.Info("MCPAuthzConfig is still referenced by workloads, blocking deletion", + "authzConfig", authzConfig.Name, + "referencingWorkloads", referencingWorkloads) + + meta.SetStatusCondition(&authzConfig.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeDeletionBlocked, + Status: metav1.ConditionTrue, + Reason: "ReferencedByWorkloads", + Message: fmt.Sprintf("Cannot delete: referenced by workloads: %v", referencingWorkloads), + ObservedGeneration: authzConfig.Generation, + }) + authzConfig.Status.ReferencingWorkloads = referencingWorkloads + authzConfig.Status.ReferenceCount = workloadReferenceCount(referencingWorkloads) + if updateErr := r.Status().Update(ctx, authzConfig); updateErr != nil { + logger.Error(updateErr, "Failed to update status during deletion block") + } + + // Requeue to check again later + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + + controllerutil.RemoveFinalizer(authzConfig, AuthzConfigFinalizerName) + if err := r.Update(ctx, authzConfig); err != nil { + logger.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err + } + logger.Info("Removed finalizer from MCPAuthzConfig", "authzConfig", authzConfig.Name) + } + + return ctrl.Result{}, nil +} + +// findReferencingWorkloads returns the workload resources (MCPServer, VirtualMCPServer, +// and MCPRemoteProxy) that reference this MCPAuthzConfig via their AuthzConfigRef field. +func (r *MCPAuthzConfigReconciler) findReferencingWorkloads( + ctx context.Context, + authzConfig *mcpv1beta1.MCPAuthzConfig, +) ([]mcpv1beta1.WorkloadReference, error) { + // Find referencing MCPServers + refs, err := ctrlutil.FindWorkloadRefsFromMCPServers(ctx, r.Client, authzConfig.Namespace, authzConfig.Name, + func(server *mcpv1beta1.MCPServer) *string { + if server.Spec.AuthzConfigRef != nil { + return &server.Spec.AuthzConfigRef.Name + } + return nil + }) + if err != nil { + return nil, err + } + + // Check VirtualMCPServers + vmcpList := &mcpv1beta1.VirtualMCPServerList{} + if err := r.List(ctx, vmcpList, client.InNamespace(authzConfig.Namespace)); err != nil { + return nil, fmt.Errorf("failed to list VirtualMCPServers: %w", err) + } + for _, vmcp := range vmcpList.Items { + if vmcp.Spec.IncomingAuth != nil && + vmcp.Spec.IncomingAuth.AuthzConfigRef != nil && + vmcp.Spec.IncomingAuth.AuthzConfigRef.Name == authzConfig.Name { + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: vmcp.Name}) + } + } + + // Check MCPRemoteProxies + proxyList := &mcpv1beta1.MCPRemoteProxyList{} + if err := r.List(ctx, proxyList, client.InNamespace(authzConfig.Namespace)); err != nil { + return nil, fmt.Errorf("failed to list MCPRemoteProxies: %w", err) + } + for _, proxy := range proxyList.Items { + if proxy.Spec.AuthzConfigRef != nil && proxy.Spec.AuthzConfigRef.Name == authzConfig.Name { + refs = append(refs, mcpv1beta1.WorkloadReference{Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: proxy.Name}) + } + } + + ctrlutil.SortWorkloadRefs(refs) + return refs, nil +} + +// SetupWithManager sets up the controller with the Manager. +// Watches MCPServer, VirtualMCPServer, and MCPRemoteProxy changes to maintain +// accurate ReferencingWorkloads status. +func (r *MCPAuthzConfigReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&mcpv1beta1.MCPAuthzConfig{}). + Watches(&mcpv1beta1.MCPServer{}, + handler.EnqueueRequestsFromMapFunc(r.mapMCPServerToAuthzConfig)). + Watches(&mcpv1beta1.VirtualMCPServer{}, + handler.EnqueueRequestsFromMapFunc(r.mapVirtualMCPServerToAuthzConfig)). + Watches(&mcpv1beta1.MCPRemoteProxy{}, + handler.EnqueueRequestsFromMapFunc(r.mapMCPRemoteProxyToAuthzConfig)). + Complete(r) +} + +// mapMCPServerToAuthzConfig maps MCPServer changes to MCPAuthzConfig reconciliation requests. +func (r *MCPAuthzConfigReconciler) mapMCPServerToAuthzConfig( + ctx context.Context, obj client.Object, +) []reconcile.Request { + server, ok := obj.(*mcpv1beta1.MCPServer) + if !ok { + return nil + } + + seen := make(map[types.NamespacedName]struct{}) + var requests []reconcile.Request + + // Enqueue the currently-referenced MCPAuthzConfig (if any) + if server.Spec.AuthzConfigRef != nil { + nn := types.NamespacedName{Name: server.Spec.AuthzConfigRef.Name, Namespace: server.Namespace} + seen[nn] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: nn}) + } + + // Also enqueue any MCPAuthzConfig that still lists this server in + // ReferencingWorkloads — handles ref-removal and server-deletion cases. + requests = append(requests, r.findStaleRefs(ctx, server.Namespace, mcpv1beta1.WorkloadKindMCPServer, server.Name, seen)...) + + return requests +} + +// mapVirtualMCPServerToAuthzConfig maps VirtualMCPServer changes to MCPAuthzConfig reconciliation requests. +func (r *MCPAuthzConfigReconciler) mapVirtualMCPServerToAuthzConfig( + ctx context.Context, obj client.Object, +) []reconcile.Request { + vmcp, ok := obj.(*mcpv1beta1.VirtualMCPServer) + if !ok { + return nil + } + + seen := make(map[types.NamespacedName]struct{}) + var requests []reconcile.Request + + if vmcp.Spec.IncomingAuth != nil && vmcp.Spec.IncomingAuth.AuthzConfigRef != nil { + nn := types.NamespacedName{Name: vmcp.Spec.IncomingAuth.AuthzConfigRef.Name, Namespace: vmcp.Namespace} + seen[nn] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: nn}) + } + + requests = append(requests, r.findStaleRefs(ctx, vmcp.Namespace, mcpv1beta1.WorkloadKindVirtualMCPServer, vmcp.Name, seen)...) + + return requests +} + +// mapMCPRemoteProxyToAuthzConfig maps MCPRemoteProxy changes to MCPAuthzConfig reconciliation requests. +func (r *MCPAuthzConfigReconciler) mapMCPRemoteProxyToAuthzConfig( + ctx context.Context, obj client.Object, +) []reconcile.Request { + proxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) + if !ok { + return nil + } + + seen := make(map[types.NamespacedName]struct{}) + var requests []reconcile.Request + + if proxy.Spec.AuthzConfigRef != nil { + nn := types.NamespacedName{Name: proxy.Spec.AuthzConfigRef.Name, Namespace: proxy.Namespace} + seen[nn] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: nn}) + } + + requests = append(requests, r.findStaleRefs(ctx, proxy.Namespace, mcpv1beta1.WorkloadKindMCPRemoteProxy, proxy.Name, seen)...) + + return requests +} + +// findStaleRefs finds MCPAuthzConfig resources that still list a workload in their +// ReferencingWorkloads status but are not in the seen set. This handles ref-removal +// and workload-deletion cases. +func (r *MCPAuthzConfigReconciler) findStaleRefs( + ctx context.Context, + namespace, workloadKind, workloadName string, + seen map[types.NamespacedName]struct{}, +) []reconcile.Request { + authzConfigList := &mcpv1beta1.MCPAuthzConfigList{} + if err := r.List(ctx, authzConfigList, client.InNamespace(namespace)); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPAuthzConfigs for workload watch", + "workloadKind", workloadKind, "workloadName", workloadName) + return nil + } + + var requests []reconcile.Request + for _, cfg := range authzConfigList.Items { + nn := types.NamespacedName{Name: cfg.Name, Namespace: cfg.Namespace} + if _, already := seen[nn]; already { + continue + } + for _, ref := range cfg.Status.ReferencingWorkloads { + if ref.Kind == workloadKind && ref.Name == workloadName { + requests = append(requests, reconcile.Request{NamespacedName: nn}) + break + } + } + } + return requests +} diff --git a/cmd/thv-operator/controllers/mcpauthzconfig_controller_test.go b/cmd/thv-operator/controllers/mcpauthzconfig_controller_test.go new file mode 100644 index 0000000000..b0e34ce8c3 --- /dev/null +++ b/cmd/thv-operator/controllers/mcpauthzconfig_controller_test.go @@ -0,0 +1,757 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + // Import authorizer backends so they register with the factory registry. + _ "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar" + _ "github.com/stacklok/toolhive/pkg/authz/authorizers/http" +) + +// validCedarConfig returns a RawExtension containing a valid Cedar backend config. +func validCedarConfig() runtime.RawExtension { + return runtime.RawExtension{ + Raw: []byte(`{"policies":["permit(principal, action, resource);"],"entities_json":"[]"}`), + } +} + +// validHTTPPDPConfig returns a RawExtension containing a valid HTTP PDP backend config. +func validHTTPPDPConfig() runtime.RawExtension { + return runtime.RawExtension{ + Raw: []byte(`{"http":{"url":"http://localhost:9000"},"claim_mapping":"standard"}`), + } +} + +func newAuthzTestReconciler(t *testing.T, objs ...client.Object) (*MCPAuthzConfigReconciler, client.Client) { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, mcpv1beta1.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithStatusSubresource(&mcpv1beta1.MCPAuthzConfig{}). + Build() + + return &MCPAuthzConfigReconciler{Client: fakeClient, Scheme: scheme}, fakeClient +} + +func TestBuildFullAuthzConfigJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec mcpv1beta1.MCPAuthzConfigSpec + expectError bool + expectType string + expectKey string + expectVersion string + }{ + { + name: "valid Cedar config produces correct JSON", + spec: mcpv1beta1.MCPAuthzConfigSpec{ + Type: "cedarv1", + Config: validCedarConfig(), + }, + expectType: "cedarv1", + expectKey: "cedar", + expectVersion: "1.0", + }, + { + name: "valid HTTP PDP config produces correct JSON", + spec: mcpv1beta1.MCPAuthzConfigSpec{ + Type: "httpv1", + Config: validHTTPPDPConfig(), + }, + expectType: "httpv1", + expectKey: "pdp", + expectVersion: "1.0", + }, + { + name: "unknown type returns error", + spec: mcpv1beta1.MCPAuthzConfigSpec{ + Type: "unknown-type", + Config: runtime.RawExtension{Raw: []byte(`{}`)}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := BuildFullAuthzConfigJSON(tt.spec) + + if tt.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + var parsed map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &parsed), "Output must be valid JSON") + + var version string + require.NoError(t, json.Unmarshal(parsed["version"], &version)) + assert.Equal(t, tt.expectVersion, version) + + var typ string + require.NoError(t, json.Unmarshal(parsed["type"], &typ)) + assert.Equal(t, tt.expectType, typ) + + _, hasKey := parsed[tt.expectKey] + assert.True(t, hasKey, "Output JSON should contain key %q", tt.expectKey) + }) + } +} + +func TestBuildFullAuthzConfigJSON_EmptyConfigRaw(t *testing.T) { + t.Parallel() + + spec := mcpv1beta1.MCPAuthzConfigSpec{ + Type: "cedarv1", + Config: runtime.RawExtension{Raw: []byte{}}, + } + + result, err := BuildFullAuthzConfigJSON(spec) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "config field is empty") +} + +func TestMCPAuthzConfigReconciler_validateAuthzConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec mcpv1beta1.MCPAuthzConfigSpec + expectError bool + }{ + { + name: "valid Cedar config with policies", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + expectError: false, + }, + { + name: "valid HTTP PDP config", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "httpv1", Config: validHTTPPDPConfig()}, + expectError: false, + }, + { + name: "empty type fails structural validation", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "", Config: validCedarConfig()}, + expectError: true, + }, + { + name: "empty config raw fails structural validation", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: runtime.RawExtension{Raw: []byte{}}}, + expectError: true, + }, + { + name: "Cedar config with empty policies fails backend validation", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: runtime.RawExtension{Raw: []byte(`{"policies":[],"entities_json":"[]"}`)}}, + expectError: true, + }, + { + name: "unknown authorizer type fails", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "nonexistent", Config: runtime.RawExtension{Raw: []byte(`{}`)}}, + expectError: true, + }, + { + name: "empty config object fails Cedar validation", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: runtime.RawExtension{Raw: []byte(`{}`)}}, + expectError: true, + }, + { + name: "HTTP PDP config missing url fails validation", + spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "httpv1", Config: runtime.RawExtension{Raw: []byte(`{"http":{},"claim_mapping":"standard"}`)}}, + expectError: true, + }, + } + + r := &MCPAuthzConfigReconciler{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := r.validateAuthzConfig(&mcpv1beta1.MCPAuthzConfig{Spec: tt.spec}) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMCPAuthzConfigReconciler_ReconcileNotFound(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r, _ := newAuthzTestReconciler(t) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "non-existent", Namespace: "default"}} + + result, err := r.Reconcile(ctx, req) + assert.NoError(t, err, "Reconciling a missing resource should not return error") + assert.Equal(t, time.Duration(0), result.RequeueAfter, "Should not requeue") +} + +func TestMCPAuthzConfigReconciler_SteadyStateNoOp(t *testing.T) { + t.Parallel() + + ctx := t.Context() + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-config", Namespace: "default", Generation: 1}, + Spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + } + r, fakeClient := newAuthzTestReconciler(t, authzConfig) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: authzConfig.Name, Namespace: authzConfig.Namespace}} + + // First reconcile: add finalizer + result, err := r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Greater(t, result.RequeueAfter, time.Duration(0)) + + // Second reconcile: set hash and condition + _, err = r.Reconcile(ctx, req) + require.NoError(t, err) + + var afterInitial mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &afterInitial)) + initialHash := afterInitial.Status.ConfigHash + initialRV := afterInitial.ResourceVersion + + // Third reconcile with no changes: should be a no-op + result, err = r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), result.RequeueAfter) + + var afterSteady mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &afterSteady)) + assert.Equal(t, initialHash, afterSteady.Status.ConfigHash, "Hash should not change") + assert.Equal(t, initialRV, afterSteady.ResourceVersion, "ResourceVersion should not change (no writes)") +} + +func TestMCPAuthzConfigReconciler_ValidationFailureSetsCondition(t *testing.T) { + t.Parallel() + + ctx := t.Context() + // Invalid config: Cedar type but empty policies + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-config", + Namespace: "default", + Finalizers: []string{AuthzConfigFinalizerName}, + Generation: 1, + }, + Spec: mcpv1beta1.MCPAuthzConfigSpec{ + Type: "cedarv1", + Config: runtime.RawExtension{Raw: []byte(`{"policies":[],"entities_json":"[]"}`)}, + }, + } + r, fakeClient := newAuthzTestReconciler(t, authzConfig) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: authzConfig.Name, Namespace: authzConfig.Namespace}} + + // Reconcile should not return error (validation failures are not requeued) + _, err := r.Reconcile(ctx, req) + require.NoError(t, err) + + var updatedConfig mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &updatedConfig)) + + var foundCondition bool + for _, cond := range updatedConfig.Status.Conditions { + if cond.Type == mcpv1beta1.ConditionTypeAuthzConfigValid { + foundCondition = true + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid condition should be False") + assert.Equal(t, mcpv1beta1.ConditionReasonAuthzConfigInvalid, cond.Reason) + break + } + } + assert.True(t, foundCondition, "Should have a Valid condition") +} + +func TestMCPAuthzConfigReconciler_handleDeletion(t *testing.T) { + t.Parallel() + + deletingConfig := func() *mcpv1beta1.MCPAuthzConfig { + return &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + Finalizers: []string{AuthzConfigFinalizerName}, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + Spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + } + } + + tests := []struct { + name string + authzConfig *mcpv1beta1.MCPAuthzConfig + existingWorkloads []client.Object + expectFinalizerRemoved bool + expectRequeue bool + }{ + { + name: "no referencing workloads removes finalizer", + authzConfig: deletingConfig(), + existingWorkloads: nil, + expectFinalizerRemoved: true, + }, + { + name: "referencing MCPServer blocks deletion", + authzConfig: deletingConfig(), + existingWorkloads: []client.Object{ + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "referencing-server", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "example/mcp:latest", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "test-config"}, + }, + }, + }, + expectRequeue: true, + }, + { + name: "referencing VirtualMCPServer blocks deletion", + authzConfig: deletingConfig(), + existingWorkloads: []client.Object{ + &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "referencing-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "test-config"}, + }, + }, + }, + }, + expectRequeue: true, + }, + { + name: "referencing MCPRemoteProxy blocks deletion", + authzConfig: deletingConfig(), + existingWorkloads: []client.Object{ + &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{Name: "referencing-proxy", Namespace: "default"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://example.com", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "test-config"}, + }, + }, + }, + expectRequeue: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + objs := append([]client.Object{tt.authzConfig}, tt.existingWorkloads...) + r, _ := newAuthzTestReconciler(t, objs...) + + result, err := r.handleDeletion(ctx, tt.authzConfig) + assert.NoError(t, err) + + if tt.expectFinalizerRemoved { + assert.NotContains(t, tt.authzConfig.Finalizers, AuthzConfigFinalizerName, "Finalizer should be removed") + assert.Equal(t, time.Duration(0), result.RequeueAfter) + } + + if tt.expectRequeue { + assert.Greater(t, result.RequeueAfter, time.Duration(0), + "Should requeue when workloads still reference the config") + assert.Contains(t, tt.authzConfig.Finalizers, AuthzConfigFinalizerName, + "Finalizer should remain when workloads reference the config") + } + }) + } +} + +func TestMCPAuthzConfigReconciler_ConfigChangeTriggersHashUpdate(t *testing.T) { + t.Parallel() + + ctx := t.Context() + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-config", Namespace: "default", Generation: 1}, + Spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + } + r, fakeClient := newAuthzTestReconciler(t, authzConfig) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: authzConfig.Name, Namespace: authzConfig.Namespace}} + + // First reconciliation - add finalizer + result, err := r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Greater(t, result.RequeueAfter, time.Duration(0), "Should requeue after adding finalizer") + + // Second reconciliation - calculate hash + result, err = r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), result.RequeueAfter) + + var updatedConfig mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &updatedConfig)) + assert.NotEmpty(t, updatedConfig.Status.ConfigHash, "Config hash should be set") + firstHash := updatedConfig.Status.ConfigHash + + // Update the config spec (simulate a change: add a second policy) + updatedConfig.Spec.Config = runtime.RawExtension{ + //nolint:lll // policy fixture is intentionally on one line + Raw: []byte(`{"policies":["permit(principal, action, resource);","forbid(principal, action, resource) when { resource.sensitive == true };"],"entities_json":"[]"}`), + } + updatedConfig.Generation = 2 + require.NoError(t, fakeClient.Update(ctx, &updatedConfig)) + + // Third reconciliation - should detect change and update hash + _, err = r.Reconcile(ctx, req) + require.NoError(t, err) + + var finalConfig mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &finalConfig)) + assert.NotEmpty(t, finalConfig.Status.ConfigHash, "Config hash should still be set") + assert.NotEqual(t, firstHash, finalConfig.Status.ConfigHash, "Hash should change when spec changes") + assert.Equal(t, int64(2), finalConfig.Status.ObservedGeneration, "ObservedGeneration should be updated") +} + +func TestMCPAuthzConfigReconciler_ValidationRecovery(t *testing.T) { + t.Parallel() + + ctx := t.Context() + // Start with invalid config: Cedar with empty policies + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "recovery-config", + Namespace: "default", + Finalizers: []string{AuthzConfigFinalizerName}, + Generation: 1, + }, + Spec: mcpv1beta1.MCPAuthzConfigSpec{ + Type: "cedarv1", + Config: runtime.RawExtension{Raw: []byte(`{"policies":[],"entities_json":"[]"}`)}, + }, + } + r, fakeClient := newAuthzTestReconciler(t, authzConfig) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: authzConfig.Name, Namespace: authzConfig.Namespace}} + + // Reconcile invalid config - should set Valid=False + _, err := r.Reconcile(ctx, req) + require.NoError(t, err) + + var invalidConfig mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &invalidConfig)) + + var foundFalse bool + for _, cond := range invalidConfig.Status.Conditions { + if cond.Type == mcpv1beta1.ConditionTypeAuthzConfigValid { + assert.Equal(t, metav1.ConditionFalse, cond.Status) + foundFalse = true + } + } + require.True(t, foundFalse, "Should have Valid=False condition") + assert.Empty(t, invalidConfig.Status.ConfigHash, "Hash should not be set for invalid config") + + // Fix the config by adding a valid policy + invalidConfig.Spec.Config = validCedarConfig() + invalidConfig.Generation = 2 + require.NoError(t, fakeClient.Update(ctx, &invalidConfig)) + + // Reconcile again - should set Valid=True and compute hash + _, err = r.Reconcile(ctx, req) + require.NoError(t, err) + + var recoveredConfig mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &recoveredConfig)) + + var foundTrue bool + for _, cond := range recoveredConfig.Status.Conditions { + if cond.Type == mcpv1beta1.ConditionTypeAuthzConfigValid { + assert.Equal(t, metav1.ConditionTrue, cond.Status, "Valid condition should recover to True") + assert.Equal(t, mcpv1beta1.ConditionReasonAuthzConfigValid, cond.Reason) + foundTrue = true + } + } + assert.True(t, foundTrue, "Should have Valid=True condition after fix") + assert.NotEmpty(t, recoveredConfig.Status.ConfigHash, "Hash should be set after recovery") +} + +func TestMCPAuthzConfigReconciler_findReferencingWorkloads(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authzConfigName string + existingWorkloads []client.Object + expectedRefs []mcpv1beta1.WorkloadReference + expectEmpty bool + }{ + { + name: "all three workload types referencing the same config", + authzConfigName: "shared-config", + existingWorkloads: []client.Object{ + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "my-server", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "example/mcp:latest", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "shared-config"}, + }, + }, + &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "my-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "shared-config"}, + }, + }, + }, + &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{Name: "my-proxy", Namespace: "default"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://example.com", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "shared-config"}, + }, + }, + }, + expectedRefs: []mcpv1beta1.WorkloadReference{ + {Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: "my-proxy"}, + {Kind: mcpv1beta1.WorkloadKindMCPServer, Name: "my-server"}, + {Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: "my-vmcp"}, + }, + }, + { + name: "no workloads reference the config", + authzConfigName: "unused-config", + existingWorkloads: []client.Object{ + &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "unrelated-server", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "example/mcp:latest", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "other-config"}, + }, + }, + &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "unrelated-vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}, + }, + }, + &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{Name: "unrelated-proxy", Namespace: "default"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{RemoteURL: "https://example.com"}, + }, + }, + expectEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r, _ := newAuthzTestReconciler(t, tt.existingWorkloads...) + + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{Name: tt.authzConfigName, Namespace: "default"}, + } + + refs, err := r.findReferencingWorkloads(ctx, authzConfig) + require.NoError(t, err) + + if tt.expectEmpty { + assert.Empty(t, refs) + } else { + assert.Equal(t, tt.expectedRefs, refs) + } + }) + } +} + +// TestMCPAuthzConfigReconciler_watchHandlers verifies that the workload watch map +// functions enqueue both the currently-referenced config and any config that still +// lists the workload in its ReferencingWorkloads status (stale-ref cleanup). +func TestMCPAuthzConfigReconciler_watchHandlers(t *testing.T) { + t.Parallel() + + // A config that still lists workloads it no longer should, to verify stale-ref enqueue. + staleConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "stale-config", Namespace: "default"}, + Spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + Status: mcpv1beta1.MCPAuthzConfigStatus{ + ReferencingWorkloads: []mcpv1beta1.WorkloadReference{ + {Kind: mcpv1beta1.WorkloadKindMCPServer, Name: "srv"}, + {Kind: mcpv1beta1.WorkloadKindVirtualMCPServer, Name: "vmcp"}, + {Kind: mcpv1beta1.WorkloadKindMCPRemoteProxy, Name: "proxy"}, + }, + }, + } + + tests := []struct { + name string + obj client.Object + mapFunc func(*MCPAuthzConfigReconciler) func(t *testing.T) []reconcile.Request + expected map[string]struct{} + }{ + { + name: "MCPServer with ref enqueues current and stale configs", + obj: &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{ + Image: "example/mcp:latest", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "current-config"}, + }, + }, + expected: map[string]struct{}{"current-config": {}, "stale-config": {}}, + }, + { + name: "VirtualMCPServer with ref enqueues current and stale configs", + obj: &mcpv1beta1.VirtualMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "vmcp", Namespace: "default"}, + Spec: mcpv1beta1.VirtualMCPServerSpec{ + IncomingAuth: &mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "current-config"}, + }, + }, + }, + expected: map[string]struct{}{"current-config": {}, "stale-config": {}}, + }, + { + name: "MCPRemoteProxy with ref enqueues current and stale configs", + obj: &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{Name: "proxy", Namespace: "default"}, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://example.com", + AuthzConfigRef: &mcpv1beta1.MCPAuthzConfigReference{Name: "current-config"}, + }, + }, + expected: map[string]struct{}{"current-config": {}, "stale-config": {}}, + }, + { + name: "MCPServer without ref only enqueues stale config", + obj: &mcpv1beta1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "srv", Namespace: "default"}, + Spec: mcpv1beta1.MCPServerSpec{Image: "example/mcp:latest"}, + }, + expected: map[string]struct{}{"stale-config": {}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r, _ := newAuthzTestReconciler(t, staleConfig, tt.obj) + + requests := func() []reconcile.Request { + switch tt.obj.(type) { + case *mcpv1beta1.MCPServer: + return r.mapMCPServerToAuthzConfig(ctx, tt.obj) + case *mcpv1beta1.VirtualMCPServer: + return r.mapVirtualMCPServerToAuthzConfig(ctx, tt.obj) + case *mcpv1beta1.MCPRemoteProxy: + return r.mapMCPRemoteProxyToAuthzConfig(ctx, tt.obj) + default: + t.Fatalf("unexpected object type %T", tt.obj) + return nil + } + }() + + got := make(map[string]struct{}, len(requests)) + for _, req := range requests { + assert.Equal(t, "default", req.Namespace) + got[req.Name] = struct{}{} + } + assert.Equal(t, tt.expected, got) + }) + } +} + +// TestMCPAuthzConfigReconciler_watchHandlersWrongType verifies the map functions +// gracefully ignore objects of an unexpected type. +func TestMCPAuthzConfigReconciler_watchHandlersWrongType(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r, _ := newAuthzTestReconciler(t) + + wrong := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"}} + assert.Nil(t, r.mapMCPServerToAuthzConfig(ctx, wrong)) + assert.Nil(t, r.mapVirtualMCPServerToAuthzConfig(ctx, wrong)) + assert.Nil(t, r.mapMCPRemoteProxyToAuthzConfig(ctx, wrong)) +} + +// TestMCPAuthzConfigReconciler_DeletionWithoutFinalizer verifies that handleDeletion +// is a no-op when the config never had the finalizer (the object is passed directly +// rather than created in the fake client, which rejects a deletionTimestamp without a +// finalizer). +func TestMCPAuthzConfigReconciler_DeletionWithoutFinalizer(t *testing.T) { + t.Parallel() + + ctx := t.Context() + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-finalizer", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + Spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + } + r, _ := newAuthzTestReconciler(t) + + result, err := r.handleDeletion(ctx, authzConfig) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), result.RequeueAfter) +} + +// TestMCPAuthzConfigReconciler_AddsFinalizer verifies the first reconcile adds the +// finalizer and requeues. +func TestMCPAuthzConfigReconciler_AddsFinalizer(t *testing.T) { + t.Parallel() + + ctx := t.Context() + authzConfig := &mcpv1beta1.MCPAuthzConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "needs-finalizer", Namespace: "default", Generation: 1}, + Spec: mcpv1beta1.MCPAuthzConfigSpec{Type: "cedarv1", Config: validCedarConfig()}, + } + r, fakeClient := newAuthzTestReconciler(t, authzConfig) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: authzConfig.Name, Namespace: authzConfig.Namespace}} + + result, err := r.Reconcile(ctx, req) + require.NoError(t, err) + assert.Greater(t, result.RequeueAfter, time.Duration(0)) + + var updated mcpv1beta1.MCPAuthzConfig + require.NoError(t, fakeClient.Get(ctx, req.NamespacedName, &updated)) + assert.Contains(t, updated.Finalizers, AuthzConfigFinalizerName) +} diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpauthzconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpauthzconfigs.yaml new file mode 100644 index 0000000000..9bc828ab5b --- /dev/null +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpauthzconfigs.yaml @@ -0,0 +1,371 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + labels: + toolhive.stacklok.dev/auto-migrate-storage-version: "true" + name: mcpauthzconfigs.toolhive.stacklok.dev +spec: + group: toolhive.stacklok.dev + names: + categories: + - toolhive + kind: MCPAuthzConfig + listKind: MCPAuthzConfigList + plural: mcpauthzconfigs + shortNames: + - authzcfg + singular: mcpauthzconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referenceCount + name: References + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 + name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPAuthzConfig is the deprecated v1alpha1 version of the MCPAuthzConfig + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig. + MCPAuthzConfig resources are namespace-scoped and can only be referenced by + MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace. + properties: + config: + description: |- + Config contains the backend-specific authorization configuration. + The structure depends on the Type field: + - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string) + - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string) + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type identifies the authorizer backend (e.g., "cedarv1", "httpv1"). + Must match a registered authorizer type in the factory registry. + minLength: 1 + type: string + required: + - config + - type + type: object + status: + description: MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPAuthzConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPAuthzConfig. + format: int64 + type: integer + referenceCount: + description: ReferenceCount is the number of workloads referencing + this config. + format: int32 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referenceCount + name: References + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: |- + MCPAuthzConfig is the Schema for the mcpauthzconfigs API. + MCPAuthzConfig resources are namespace-scoped and can only be referenced by + MCPServer, MCPRemoteProxy, or VirtualMCPServer resources within the same namespace. + Cross-namespace references are not supported for security and isolation reasons. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig. + MCPAuthzConfig resources are namespace-scoped and can only be referenced by + MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace. + properties: + config: + description: |- + Config contains the backend-specific authorization configuration. + The structure depends on the Type field: + - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string) + - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string) + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type identifies the authorizer backend (e.g., "cedarv1", "httpv1"). + Must match a registered authorizer type in the factory registry. + minLength: 1 + type: string + required: + - config + - type + type: object + status: + description: MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPAuthzConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPAuthzConfig. + format: int64 + type: integer + referenceCount: + description: ReferenceCount is the number of workloads referencing + this config. + format: int32 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 8717797c17..a08b2e3ef8 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -97,8 +97,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the proxy + description: |- + AuthzConfig defines authorization policy configuration for the proxy. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -206,6 +208,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object endpointPrefix: description: |- EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. @@ -525,6 +541,10 @@ spec: required: - remoteUrl type: object + x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: @@ -716,8 +736,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the proxy + description: |- + AuthzConfig defines authorization policy configuration for the proxy. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -825,6 +847,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object endpointPrefix: description: |- EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. @@ -1144,6 +1180,10 @@ spec: required: - remoteUrl type: object + x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml index 936af5992b..bd59e62e6e 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml @@ -104,8 +104,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the MCP server + description: |- + AuthzConfig defines authorization policy configuration for the MCP server. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -213,6 +215,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object backendReplicas: description: |- BackendReplicas is the desired number of MCP server backend pod replicas. @@ -810,6 +826,9 @@ spec: - image type: object x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' @@ -1021,8 +1040,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the MCP server + description: |- + AuthzConfig defines authorization policy configuration for the MCP server. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -1130,6 +1151,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object backendReplicas: description: |- BackendReplicas is the desired number of MCP server backend pod replicas. @@ -1727,6 +1762,9 @@ spec: - image type: object x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 6e19f377d4..8475f9fcb4 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2419,8 +2419,10 @@ spec: properties: authzConfig: description: |- - AuthzConfig defines authorization policy configuration - Reuses MCPServer authz patterns + AuthzConfig defines authorization policy configuration. + Reuses MCPServer authz patterns. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -2528,6 +2530,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource + in the same namespace. + minLength: 1 + type: string + required: + - name + type: object oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. @@ -2579,6 +2595,9 @@ spec: - message: spec.incomingAuth.oidcConfigRef is required when type is oidc rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' + - message: authzConfig and authzConfigRef are mutually exclusive; + use authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' outgoingAuth: description: |- OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. @@ -5343,8 +5362,10 @@ spec: properties: authzConfig: description: |- - AuthzConfig defines authorization policy configuration - Reuses MCPServer authz patterns + AuthzConfig defines authorization policy configuration. + Reuses MCPServer authz patterns. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -5452,6 +5473,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource + in the same namespace. + minLength: 1 + type: string + required: + - name + type: object oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. @@ -5503,6 +5538,9 @@ spec: - message: spec.incomingAuth.oidcConfigRef is required when type is oidc rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' + - message: authzConfig and authzConfigRef are mutually exclusive; + use authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' outgoingAuth: description: |- OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpauthzconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpauthzconfigs.yaml new file mode 100644 index 0000000000..fcc3571c0f --- /dev/null +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpauthzconfigs.yaml @@ -0,0 +1,375 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.17.3 + labels: + toolhive.stacklok.dev/auto-migrate-storage-version: "true" + name: mcpauthzconfigs.toolhive.stacklok.dev +spec: + group: toolhive.stacklok.dev + names: + categories: + - toolhive + kind: MCPAuthzConfig + listKind: MCPAuthzConfigList + plural: mcpauthzconfigs + shortNames: + - authzcfg + singular: mcpauthzconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referenceCount + name: References + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + deprecated: true + deprecationWarning: toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1 + name: v1alpha1 + schema: + openAPIV3Schema: + description: MCPAuthzConfig is the deprecated v1alpha1 version of the MCPAuthzConfig + resource. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig. + MCPAuthzConfig resources are namespace-scoped and can only be referenced by + MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace. + properties: + config: + description: |- + Config contains the backend-specific authorization configuration. + The structure depends on the Type field: + - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string) + - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string) + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type identifies the authorizer backend (e.g., "cedarv1", "httpv1"). + Must match a registered authorizer type in the factory registry. + minLength: 1 + type: string + required: + - config + - type + type: object + status: + description: MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPAuthzConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPAuthzConfig. + format: int64 + type: integer + referenceCount: + description: ReferenceCount is the number of workloads referencing + this config. + format: int32 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=='Valid')].status + name: Valid + type: string + - jsonPath: .status.referenceCount + name: References + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: |- + MCPAuthzConfig is the Schema for the mcpauthzconfigs API. + MCPAuthzConfig resources are namespace-scoped and can only be referenced by + MCPServer, MCPRemoteProxy, or VirtualMCPServer resources within the same namespace. + Cross-namespace references are not supported for security and isolation reasons. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig. + MCPAuthzConfig resources are namespace-scoped and can only be referenced by + MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace. + properties: + config: + description: |- + Config contains the backend-specific authorization configuration. + The structure depends on the Type field: + - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string) + - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string) + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type identifies the authorizer backend (e.g., "cedarv1", "httpv1"). + Must match a registered authorizer type in the factory registry. + minLength: 1 + type: string + required: + - config + - type + type: object + status: + description: MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig + properties: + conditions: + description: Conditions represent the latest available observations + of the MCPAuthzConfig's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + configHash: + description: ConfigHash is a hash of the current configuration for + change detection + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this MCPAuthzConfig. + format: int64 + type: integer + referenceCount: + description: ReferenceCount is the number of workloads referencing + this config. + format: int32 + type: integer + referencingWorkloads: + description: |- + ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig. + Each entry identifies the workload by kind and name. + items: + description: |- + WorkloadReference identifies a workload that references a shared configuration resource. + Namespace is implicit — cross-namespace references are not supported. + properties: + kind: + description: Kind is the type of workload resource + enum: + - MCPServer + - VirtualMCPServer + - MCPRemoteProxy + type: string + name: + description: Name is the name of the workload resource + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index 70e8b89b5d..232f587829 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -100,8 +100,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the proxy + description: |- + AuthzConfig defines authorization policy configuration for the proxy. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -209,6 +211,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object endpointPrefix: description: |- EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. @@ -528,6 +544,10 @@ spec: required: - remoteUrl type: object + x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: @@ -719,8 +739,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the proxy + description: |- + AuthzConfig defines authorization policy configuration for the proxy. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -828,6 +850,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object endpointPrefix: description: |- EndpointPrefix is the path prefix to prepend to SSE endpoint URLs. @@ -1147,6 +1183,10 @@ spec: required: - remoteUrl type: object + x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' status: description: MCPRemoteProxyStatus defines the observed state of MCPRemoteProxy properties: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml index 9a5f014703..44c9ef42de 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml @@ -107,8 +107,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the MCP server + description: |- + AuthzConfig defines authorization policy configuration for the MCP server. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -216,6 +218,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object backendReplicas: description: |- BackendReplicas is the desired number of MCP server backend pod replicas. @@ -813,6 +829,9 @@ spec: - image type: object x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' @@ -1024,8 +1043,10 @@ spec: - name type: object authzConfig: - description: AuthzConfig defines authorization policy configuration - for the MCP server + description: |- + AuthzConfig defines authorization policy configuration for the MCP server. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -1133,6 +1154,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource in + the same namespace. + minLength: 1 + type: string + required: + - name + type: object backendReplicas: description: |- BackendReplicas is the desired number of MCP server backend pod replicas. @@ -1730,6 +1765,9 @@ spec: - image type: object x-kubernetes-validations: + - message: authzConfig and authzConfigRef are mutually exclusive; use + authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' - message: rateLimiting requires sessionStorage with provider 'redis' rule: '!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == ''redis'')' diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 3f808265de..0df5dcf303 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2422,8 +2422,10 @@ spec: properties: authzConfig: description: |- - AuthzConfig defines authorization policy configuration - Reuses MCPServer authz patterns + AuthzConfig defines authorization policy configuration. + Reuses MCPServer authz patterns. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -2531,6 +2533,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource + in the same namespace. + minLength: 1 + type: string + required: + - name + type: object oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. @@ -2582,6 +2598,9 @@ spec: - message: spec.incomingAuth.oidcConfigRef is required when type is oidc rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' + - message: authzConfig and authzConfigRef are mutually exclusive; + use authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' outgoingAuth: description: |- OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. @@ -5346,8 +5365,10 @@ spec: properties: authzConfig: description: |- - AuthzConfig defines authorization policy configuration - Reuses MCPServer authz patterns + AuthzConfig defines authorization policy configuration. + Reuses MCPServer authz patterns. + Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead. + AuthzConfig and AuthzConfigRef are mutually exclusive. properties: configMap: description: |- @@ -5455,6 +5476,20 @@ spec: - message: inline must be set when type is 'inline', and must not be set otherwise rule: 'self.type == ''inline'' ? has(self.inline) : !has(self.inline)' + authzConfigRef: + description: |- + AuthzConfigRef references a shared MCPAuthzConfig resource for authorization. + The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer. + Mutually exclusive with authzConfig. + properties: + name: + description: Name is the name of the MCPAuthzConfig resource + in the same namespace. + minLength: 1 + type: string + required: + - name + type: object oidcConfigRef: description: |- OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication. @@ -5506,6 +5541,9 @@ spec: - message: spec.incomingAuth.oidcConfigRef is required when type is oidc rule: 'self.type == ''oidc'' ? has(self.oidcConfigRef) : true' + - message: authzConfig and authzConfigRef are mutually exclusive; + use authzConfigRef to reference a shared MCPAuthzConfig + rule: '!(has(self.authzConfig) && has(self.authzConfigRef))' outgoingAuth: description: |- OutgoingAuth configures authentication from Virtual MCP to backend MCPServers. diff --git a/deploy/charts/operator/templates/clusterrole/role.yaml b/deploy/charts/operator/templates/clusterrole/role.yaml index cde9d983e7..f4ddc88f92 100644 --- a/deploy/charts/operator/templates/clusterrole/role.yaml +++ b/deploy/charts/operator/templates/clusterrole/role.yaml @@ -122,6 +122,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers + - mcpauthzconfigs - mcpexternalauthconfigs - mcpgroups - mcpoidcconfigs @@ -143,6 +144,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers/finalizers + - mcpauthzconfigs/finalizers - mcpexternalauthconfigs/finalizers - mcpgroups/finalizers - mcpoidcconfigs/finalizers @@ -157,6 +159,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers/status + - mcpauthzconfigs/status - mcpexternalauthconfigs/status - mcpgroups/status - mcpoidcconfigs/status diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index b8f04ff351..64b840eaba 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -850,6 +850,8 @@ _Appears in:_ ### Resource Types - [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) - [api.v1alpha1.EmbeddingServerList](#apiv1alpha1embeddingserverlist) +- [api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) +- [api.v1alpha1.MCPAuthzConfigList](#apiv1alpha1mcpauthzconfiglist) - [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) - [api.v1alpha1.MCPExternalAuthConfigList](#apiv1alpha1mcpexternalauthconfiglist) - [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) @@ -925,6 +927,10 @@ _Appears in:_ + + + + @@ -934,6 +940,8 @@ _Appears in:_ ### Resource Types - [api.v1beta1.EmbeddingServer](#apiv1beta1embeddingserver) - [api.v1beta1.EmbeddingServerList](#apiv1beta1embeddingserverlist) +- [api.v1beta1.MCPAuthzConfig](#apiv1beta1mcpauthzconfig) +- [api.v1beta1.MCPAuthzConfigList](#apiv1beta1mcpauthzconfiglist) - [api.v1beta1.MCPExternalAuthConfig](#apiv1beta1mcpexternalauthconfig) - [api.v1beta1.MCPExternalAuthConfigList](#apiv1beta1mcpexternalauthconfiglist) - [api.v1beta1.MCPGroup](#apiv1beta1mcpgroup) @@ -1537,7 +1545,8 @@ _Appears in:_ | --- | --- | --- | --- | | `type` _string_ | Type defines the authentication type: anonymous or oidc
When no authentication is required, explicitly set this to "anonymous" | | Enum: [anonymous oidc]
Required: \{\}
| | `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this VirtualMCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| -| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration
Reuses MCPServer authz patterns | | Optional: \{\}
| +| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration.
Reuses MCPServer authz patterns.
Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead.
AuthzConfig and AuthzConfigRef are mutually exclusive. | | Optional: \{\}
| +| `authzConfigRef` _[api.v1beta1.MCPAuthzConfigReference](#apiv1beta1mcpauthzconfigreference)_ | AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer.
Mutually exclusive with authzConfig. | | Optional: \{\}
| #### api.v1beta1.InlineAuthzConfig @@ -1611,6 +1620,109 @@ _Appears in:_ | `useClusterAuth` _boolean_ | UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token.
When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification
and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication.
Defaults to true if not specified. | | Optional: \{\}
| +#### api.v1beta1.MCPAuthzConfig + + + +MCPAuthzConfig is the Schema for the mcpauthzconfigs API. +MCPAuthzConfig resources are namespace-scoped and can only be referenced by +MCPServer, MCPRemoteProxy, or VirtualMCPServer resources within the same namespace. +Cross-namespace references are not supported for security and isolation reasons. + + + +_Appears in:_ +- [api.v1beta1.MCPAuthzConfigList](#apiv1beta1mcpauthzconfiglist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | +| `kind` _string_ | `MCPAuthzConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPAuthzConfigSpec](#apiv1beta1mcpauthzconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPAuthzConfigStatus](#apiv1beta1mcpauthzconfigstatus)_ | | | | + + +#### api.v1beta1.MCPAuthzConfigList + + + +MCPAuthzConfigList contains a list of MCPAuthzConfig + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1beta1` | | | +| `kind` _string_ | `MCPAuthzConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1beta1.MCPAuthzConfig](#apiv1beta1mcpauthzconfig) array_ | | | | + + +#### api.v1beta1.MCPAuthzConfigReference + + + +MCPAuthzConfigReference references a shared MCPAuthzConfig resource. +The referenced MCPAuthzConfig must be in the same namespace as the referencing workload. + + + +_Appears in:_ +- [api.v1beta1.IncomingAuthConfig](#apiv1beta1incomingauthconfig) +- [api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec) +- [api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the MCPAuthzConfig resource in the same namespace. | | MinLength: 1
Required: \{\}
| + + +#### api.v1beta1.MCPAuthzConfigSpec + + + +MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig. +MCPAuthzConfig resources are namespace-scoped and can only be referenced by +MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace. + + + +_Appears in:_ +- [api.v1beta1.MCPAuthzConfig](#apiv1beta1mcpauthzconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type identifies the authorizer backend (e.g., "cedarv1", "httpv1").
Must match a registered authorizer type in the factory registry. | | MinLength: 1
Required: \{\}
| +| `config` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | Config contains the backend-specific authorization configuration.
The structure depends on the Type field:
- cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string)
- httpv1: http (\{url, timeout, insecure_skip_verify\}), context (\{include_args, include_operation\}), claim_mapping (string) | | Type: object
| + + +#### api.v1beta1.MCPAuthzConfigStatus + + + +MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig + + + +_Appears in:_ +- [api.v1beta1.MCPAuthzConfig](#apiv1beta1mcpauthzconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta) array_ | Conditions represent the latest available observations of the MCPAuthzConfig's state | | Optional: \{\}
| +| `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this MCPAuthzConfig. | | Optional: \{\}
| +| `configHash` _string_ | ConfigHash is a hash of the current configuration for change detection | | Optional: \{\}
| +| `referenceCount` _integer_ | ReferenceCount is the number of workloads referencing this config. | | Optional: \{\}
| +| `referencingWorkloads` _[api.v1beta1.WorkloadReference](#apiv1beta1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig.
Each entry identifies the workload by kind and name. | | Optional: \{\}
| + + #### api.v1beta1.MCPExternalAuthConfig @@ -2134,7 +2246,8 @@ _Appears in:_ | `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange.
When specified, the proxy will exchange validated incoming tokens for remote service tokens.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPRemoteProxy. | | Optional: \{\}
| | `authServerRef` _[api.v1beta1.AuthServerRef](#apiv1beta1authserverref)_ | AuthServerRef optionally references a resource that configures an embedded
OAuth 2.0/OIDC authorization server to authenticate MCP clients.
Currently the only supported kind is MCPExternalAuthConfig (type: embeddedAuthServer). | | Optional: \{\}
| | `headerForward` _[api.v1beta1.HeaderForwardConfig](#apiv1beta1headerforwardconfig)_ | HeaderForward configures headers to inject into requests to the remote MCP server.
Use this to add custom headers like X-Tenant-ID or correlation IDs. | | Optional: \{\}
| -| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the proxy | | Optional: \{\}
| +| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the proxy.
Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead.
AuthzConfig and AuthzConfigRef are mutually exclusive. | | Optional: \{\}
| +| `authzConfigRef` _[api.v1beta1.MCPAuthzConfigReference](#apiv1beta1mcpauthzconfigreference)_ | AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy.
Mutually exclusive with authzConfig. | | Optional: \{\}
| | `audit` _[api.v1beta1.AuditConfig](#apiv1beta1auditconfig)_ | Audit defines audit logging configuration for the proxy | | Optional: \{\}
| | `toolConfigRef` _[api.v1beta1.ToolConfigRef](#apiv1beta1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons.
If specified, this allows filtering and overriding tools from the remote MCP server. | | Optional: \{\}
| | `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| @@ -2368,7 +2481,8 @@ _Appears in:_ | `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the MCP server
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the MCP server runs in, you must specify
the `mcp` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `resourceOverrides` _[api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\}
| | `oidcConfigRef` _[api.v1beta1.MCPOIDCConfigReference](#apiv1beta1mcpoidcconfigreference)_ | OIDCConfigRef references a shared MCPOIDCConfig resource for OIDC authentication.
The referenced MCPOIDCConfig must exist in the same namespace as this MCPServer.
Per-server overrides (audience, scopes) are specified here; shared provider config
lives in the MCPOIDCConfig resource. | | Optional: \{\}
| -| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the MCP server | | Optional: \{\}
| +| `authzConfig` _[api.v1beta1.AuthzConfigRef](#apiv1beta1authzconfigref)_ | AuthzConfig defines authorization policy configuration for the MCP server.
Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead.
AuthzConfig and AuthzConfigRef are mutually exclusive. | | Optional: \{\}
| +| `authzConfigRef` _[api.v1beta1.MCPAuthzConfigReference](#apiv1beta1mcpauthzconfigreference)_ | AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer.
Mutually exclusive with authzConfig. | | Optional: \{\}
| | `audit` _[api.v1beta1.AuditConfig](#apiv1beta1auditconfig)_ | Audit defines audit logging configuration for the MCP server | | Optional: \{\}
| | `toolConfigRef` _[api.v1beta1.ToolConfigRef](#apiv1beta1toolconfigref)_ | ToolConfigRef references a MCPToolConfig resource for tool filtering and renaming.
The referenced MCPToolConfig must exist in the same namespace as this MCPServer.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `externalAuthConfigRef` _[api.v1beta1.ExternalAuthConfigRef](#apiv1beta1externalauthconfigref)_ | ExternalAuthConfigRef references a MCPExternalAuthConfig resource for external authentication.
The referenced MCPExternalAuthConfig must exist in the same namespace as this MCPServer. | | Optional: \{\}
| @@ -3771,6 +3885,7 @@ Namespace is implicit — cross-namespace references are not supported. _Appears in:_ +- [api.v1beta1.MCPAuthzConfigStatus](#apiv1beta1mcpauthzconfigstatus) - [api.v1beta1.MCPExternalAuthConfigStatus](#apiv1beta1mcpexternalauthconfigstatus) - [api.v1beta1.MCPOIDCConfigStatus](#apiv1beta1mcpoidcconfigstatus) - [api.v1beta1.MCPTelemetryConfigStatus](#apiv1beta1mcptelemetryconfigstatus) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index a0de7a2c2c..3169b86888 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -98,6 +98,9 @@ func InjectUpstreamProvider(src *authorizers.Config, providerName string) (*auth // Factory implements the authorizers.AuthorizerFactory interface for Cedar. type Factory struct{} +// ConfigKey returns the JSON key for Cedar-specific configuration ("cedar"). +func (*Factory) ConfigKey() string { return "cedar" } + // ValidateConfig validates the Cedar-specific configuration. // It receives the full raw config and extracts the Cedar-specific portion. func (*Factory) ValidateConfig(rawConfig json.RawMessage) error { diff --git a/pkg/authz/authorizers/config_test.go b/pkg/authz/authorizers/config_test.go index cd30d58c8f..241c70e961 100644 --- a/pkg/authz/authorizers/config_test.go +++ b/pkg/authz/authorizers/config_test.go @@ -20,6 +20,10 @@ const testConfigType = "test-config-type" // testFactory is a simple test factory for config tests type testFactory struct{} +func (*testFactory) ConfigKey() string { + return "test" +} + func (*testFactory) ValidateConfig(rawConfig json.RawMessage) error { var config struct { TestField string `json:"test_field"` diff --git a/pkg/authz/authorizers/http/core.go b/pkg/authz/authorizers/http/core.go index f51da15dee..173dfe1130 100644 --- a/pkg/authz/authorizers/http/core.go +++ b/pkg/authz/authorizers/http/core.go @@ -21,6 +21,9 @@ func init() { // Factory implements the authorizers.AuthorizerFactory interface for HTTP PDPs. type Factory struct{} +// ConfigKey returns the JSON key for HTTP PDP-specific configuration ("pdp"). +func (*Factory) ConfigKey() string { return "pdp" } + // ValidateConfig validates the HTTP PDP configuration. func (*Factory) ValidateConfig(rawConfig json.RawMessage) error { config, err := parseConfig(rawConfig) diff --git a/pkg/authz/authorizers/registry.go b/pkg/authz/authorizers/registry.go index c0cd7e3c41..ced43d55d0 100644 --- a/pkg/authz/authorizers/registry.go +++ b/pkg/authz/authorizers/registry.go @@ -14,6 +14,11 @@ import ( // (e.g., Cedar, OPA) implements this interface to provide validation and // instantiation of authorizers from their specific configuration format. type AuthorizerFactory interface { + // ConfigKey returns the JSON key under which the backend-specific + // configuration is nested in the full authorizer config blob. + // For example, Cedar returns "cedar" and HTTP PDP returns "pdp". + ConfigKey() string + // ValidateConfig validates the authorizer-specific configuration. // The rawConfig is the JSON-encoded authorizer configuration. ValidateConfig(rawConfig json.RawMessage) error diff --git a/pkg/authz/authorizers/registry_test.go b/pkg/authz/authorizers/registry_test.go index ddfa03b9ce..29187a41d0 100644 --- a/pkg/authz/authorizers/registry_test.go +++ b/pkg/authz/authorizers/registry_test.go @@ -18,6 +18,10 @@ type mockFactory struct { authorizer Authorizer } +func (*mockFactory) ConfigKey() string { + return "mock" +} + func (f *mockFactory) ValidateConfig(_ json.RawMessage) error { return f.validateErr } diff --git a/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml b/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml index cde9d983e7..f4ddc88f92 100644 --- a/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml +++ b/test/e2e/chainsaw/operator/multi-tenancy/setup/assert-rbac-clusterrole.yaml @@ -122,6 +122,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers + - mcpauthzconfigs - mcpexternalauthconfigs - mcpgroups - mcpoidcconfigs @@ -143,6 +144,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers/finalizers + - mcpauthzconfigs/finalizers - mcpexternalauthconfigs/finalizers - mcpgroups/finalizers - mcpoidcconfigs/finalizers @@ -157,6 +159,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers/status + - mcpauthzconfigs/status - mcpexternalauthconfigs/status - mcpgroups/status - mcpoidcconfigs/status diff --git a/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml b/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml index cde9d983e7..f4ddc88f92 100644 --- a/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml +++ b/test/e2e/chainsaw/operator/single-tenancy/setup/assert-rbac-clusterrole.yaml @@ -122,6 +122,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers + - mcpauthzconfigs - mcpexternalauthconfigs - mcpgroups - mcpoidcconfigs @@ -143,6 +144,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers/finalizers + - mcpauthzconfigs/finalizers - mcpexternalauthconfigs/finalizers - mcpgroups/finalizers - mcpoidcconfigs/finalizers @@ -157,6 +159,7 @@ rules: - toolhive.stacklok.dev resources: - embeddingservers/status + - mcpauthzconfigs/status - mcpexternalauthconfigs/status - mcpgroups/status - mcpoidcconfigs/status