Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/thv-operator/api/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this? As MCPAuthzConfig is a new CRD?

//+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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't true?

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
Expand Down Expand Up @@ -397,6 +426,7 @@ type VirtualMCPServerList struct {
func init() {
SchemeBuilder.Register(
&EmbeddingServer{}, &EmbeddingServerList{},
&MCPAuthzConfig{}, &MCPAuthzConfigList{},
&MCPExternalAuthConfig{}, &MCPExternalAuthConfigList{},
&MCPGroup{}, &MCPGroupList{},
&MCPOIDCConfig{}, &MCPOIDCConfigList{},
Expand Down
59 changes: 59 additions & 0 deletions cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

134 changes: 134 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpauthzconfig_types.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] No Validate() method on MCPAuthzConfig type (Consensus: 7/10)

Other shared config CRDs (MCPOIDCConfig, MCPTelemetryConfig, MCPExternalAuthConfig) define a Validate() method on the type itself, called from the controller as config.Validate(). This CRD uses a standalone validateAuthzConfigSpec() function in the controller package instead.

The deviation is understandable (validation requires the factory registry, an external dependency), but a comment in the controller explaining why would help maintainability.

Raised by: toolhive-expert, code-reviewer

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"`
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] ReferencingWorkloads listMapKey=name may be insufficient (Consensus: 7/10)

Using only name as the list map key could collide if an MCPServer and a VirtualMCPServer share the same name in the same namespace. The composite key should be kind + name:

// +listMapKey=kind
// +listMapKey=name

This is a pre-existing pattern copied from MCPOIDCConfig, MCPTelemetryConfig, etc. -- consider fixing across all config CRDs in a separate PR.

Raised by: kubernetes-expert

// +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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F13 — LOW: Validate() only checks Type != "" and len(Config.Raw) != 0. Whether the type is actually registered is checked only inside the controller via authorizers.GetFactory(cfg.Type). Other callers of Validate() (a future webhook, CLI tooling, an admission-style helper) would happily accept an unknown type as "valid".

Consider consulting the authorizers registry from Validate() directly so all callers fail-closed:

Suggested change
func (r *MCPAuthzConfig) Validate() error {
func (r *MCPAuthzConfig) Validate() error {
if r.Spec.Type == "" {
return fmt.Errorf("spec.type is required")
}
if !authorizers.IsRegistered(r.Spec.Type) {
return fmt.Errorf("spec.type %q is not a registered authorizer backend (registered: %v)", r.Spec.Type, authorizers.RegisteredTypes())
}
if len(r.Spec.Config.Raw) == 0 {

(Adjust the helper name to whatever the registry actually exposes.)

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{})
}
60 changes: 60 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpauthzconfig_types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
14 changes: 13 additions & 1 deletion cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
11 changes: 10 additions & 1 deletion cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F7 — MEDIUM: This XValidation rule (and the parallel rules on MCPRemoteProxySpec and IncomingAuthConfig) is admission-time only, so unit tests with the fake client can't exercise it. There are no envtest cases that apply each CR with (a) only authzConfig, (b) only authzConfigRef, (c) both set — only (c) should be rejected.

Please add envtest coverage for all three new mutual-exclusion rules. This is the exact scenario the .claude/rules/operator.md testing guidance points at envtest for (over chainsaw).

// +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)"
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F16 — LOW: Marking the inline field Deprecated: is correct in intent, but there is no migration guide for users today: no conversion table (inline.policiesMCPAuthzConfig.spec.config.policies), no operator-side auto-conversion, and no docs link from this comment. Combined with F1 (no workload controller consumes AuthzConfigRef yet), a user attempting migration in the wrong order ends up with no enforcement.

Either defer the Deprecated: annotation until #4778 lands, or add a short pointer to a migration doc (e.g. docs/operator/migration-authzconfig.md).

// 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"`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Missing CEL XValidation for authzConfig/authzConfigRef mutual exclusivity (Consensus: 10/10)

The AuthzConfig and AuthzConfigRef fields are documented as "mutually exclusive" but no CEL validation rule enforces this at the API server level. The existing OIDC pattern has:

// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive..."

The same rule is needed here on MCPServerSpec, MCPRemoteProxySpec, and IncomingAuthConfig:

// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"

Without this, users can set both fields simultaneously and the controller must decide which takes precedence.

Raised by: kubernetes-expert, code-reviewer, toolhive-expert

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F1 — MEDIUM: AuthzConfigRef is added here (and to MCPRemoteProxy/VirtualMCPServer) but no workload controller resolves it into a runtime authz config in this PR. Grepping the repo shows Spec.AuthzConfigRef only read by the new MCPAuthzConfigReconciler for bookkeeping (finalizer / ReferenceCount). A user who follows the Deprecated: Use AuthzConfigRef ... guidance on the sibling inline field will get a successfully reconciled MCPServer with no authorization enforced. The PR description discloses this is part 1/2 with #4778 stacked, but the field-level docs and the Deprecated: annotation get ahead of the actual capability.

Consider one of:

  1. Hold the Deprecated: annotation on the inline AuthzConfig field (and the equivalent on MCPRemoteProxy/VirtualMCPServer) until Wire MCPAuthzConfig references into workload controllers #4778 lands.
  2. Add a controller-side validation that emits a Warning event when AuthzConfigRef is set on a workload until the consumer ships, so users aren't silently mis-configured.
  3. Note the staging gap in the field's godoc (e.g. "Consumed by workload controllers in Wire MCPAuthzConfig references into workload controllers #4778; until then this field is reference-tracking only and does not apply authorization.").


// Audit defines audit logging configuration for the MCP server
// +optional
Audit *AuditConfig `json:"audit,omitempty"`
Expand Down
13 changes: 11 additions & 2 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading