Skip to content

Commit d571e89

Browse files
JAORMXclaude
andcommitted
Address review feedback on MCPAuthzConfig CRD
- Add CEL XValidation rules for authzConfig/authzConfigRef mutual exclusivity on MCPServerSpec, MCPRemoteProxySpec, and IncomingAuthConfig - Add RBAC markers for watched workload resources (mcpservers, virtualmcpservers, mcpremoteproxies) - Replace mustMarshalJSON panic with error-returning marshalJSON - Add nil-check on spec.Config.Raw before use in BuildFullAuthzConfigJSON - Fix reconcile logic: call findReferencingWorkloads before hash-changed check and return errors instead of swallowing them (matches MCPTelemetryConfig pattern) - Move authorizer backend blank imports from controller to main.go - Add comment explaining why validateAuthzConfigSpec is standalone - Add listMapKey=kind to ReferencingWorkloads for composite uniqueness - Add tests for findReferencingWorkloads covering all workload types, deletion blocking by VirtualMCPServer and MCPRemoteProxy, and empty Config.Raw validation - Regenerate CRD manifests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 284a5d7 commit d571e89

15 files changed

Lines changed: 288 additions & 28 deletions

cmd/thv-operator/api/v1alpha1/mcpauthzconfig_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type MCPAuthzConfigStatus struct {
5858
// ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig.
5959
// Each entry identifies the workload by kind and name.
6060
// +listType=map
61+
// +listMapKey=kind
6162
// +listMapKey=name
6263
// +optional
6364
ReferencingWorkloads []WorkloadReference `json:"referencingWorkloads,omitempty"`

cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type HeaderFromSecret struct {
3838
// MCPRemoteProxySpec defines the desired state of MCPRemoteProxy
3939
//
4040
// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig"
41+
// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"
4142
// +kubebuilder:validation:XValidation:rule="!(has(self.telemetry) && has(self.telemetryConfigRef))",message="telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef"
4243
//
4344
//nolint:lll // CEL validation rules exceed line length limit

cmd/thv-operator/api/v1alpha1/mcpserver_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ const SessionStorageProviderRedis = "redis"
176176
// MCPServerSpec defines the desired state of MCPServer
177177
//
178178
// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig"
179+
// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"
179180
// +kubebuilder:validation:XValidation:rule="!(has(self.telemetry) && has(self.telemetryConfigRef))",message="telemetry and telemetryConfigRef are mutually exclusive; migrate to telemetryConfigRef"
180181
// +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="rateLimiting requires sessionStorage with provider 'redis'"
181182
// +kubebuilder:validation:XValidation:rule="!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || has(self.oidcConfig) || has(self.oidcConfigRef) || has(self.externalAuthConfigRef)",message="rateLimiting.perUser requires authentication (oidcConfig, oidcConfigRef, or externalAuthConfigRef)"

cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ type EmbeddingServerRef struct {
111111
//
112112
// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? (has(self.oidcConfig) || has(self.oidcConfigRef)) : true",message="spec.incomingAuth.oidcConfig or oidcConfigRef is required when type is oidc"
113113
// +kubebuilder:validation:XValidation:rule="!(has(self.oidcConfig) && has(self.oidcConfigRef))",message="oidcConfig and oidcConfigRef are mutually exclusive; use oidcConfigRef to reference a shared MCPOIDCConfig"
114+
// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"
114115
//
115116
//nolint:lll // CEL validation rules exceed line length limit
116117
type IncomingAuthConfig struct {

cmd/thv-operator/controllers/mcpauthzconfig_controller.go

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ import (
2424
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
2525
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
2626
"github.com/stacklok/toolhive/pkg/authz/authorizers"
27-
// Import authorizer backends so they register with the factory registry.
28-
_ "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
29-
_ "github.com/stacklok/toolhive/pkg/authz/authorizers/http"
3027
)
3128

3229
const (
@@ -53,6 +50,9 @@ type MCPAuthzConfigReconciler struct {
5350
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpauthzconfigs,verbs=get;list;watch;create;update;patch;delete
5451
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpauthzconfigs/status,verbs=get;update;patch
5552
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpauthzconfigs/finalizers,verbs=update
53+
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers,verbs=get;list;watch
54+
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=virtualmcpservers,verbs=get;list;watch
55+
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies,verbs=get;list;watch
5656

5757
// Reconcile is part of the main kubernetes reconciliation loop which aims to
5858
// move the current state of the cluster closer to the desired state.
@@ -114,36 +114,31 @@ func (r *MCPAuthzConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reque
114114
// Calculate the hash of the current configuration
115115
configHash := ctrlutil.CalculateConfigHash(authzConfig.Spec)
116116

117-
// Check if the hash has changed
117+
// Track referencing workloads
118+
referencingWorkloads, err := r.findReferencingWorkloads(ctx, authzConfig)
119+
if err != nil {
120+
logger.Error(err, "Failed to find referencing workloads")
121+
return ctrl.Result{}, err
122+
}
123+
124+
// Check what changed
118125
hashChanged := authzConfig.Status.ConfigHash != configHash
126+
refsChanged := !ctrlutil.WorkloadRefsEqual(authzConfig.Status.ReferencingWorkloads, referencingWorkloads)
127+
needsUpdate := hashChanged || refsChanged || conditionChanged
128+
119129
if hashChanged {
120130
logger.Info("MCPAuthzConfig configuration changed",
121131
"oldHash", authzConfig.Status.ConfigHash,
122132
"newHash", configHash)
133+
}
123134

135+
if needsUpdate {
124136
authzConfig.Status.ConfigHash = configHash
125137
authzConfig.Status.ObservedGeneration = authzConfig.Generation
126-
127-
if err := r.Status().Update(ctx, authzConfig); err != nil {
128-
logger.Error(err, "Failed to update MCPAuthzConfig status")
129-
return ctrl.Result{}, err
130-
}
131-
return ctrl.Result{}, nil
132-
}
133-
134-
// Refresh ReferencingWorkloads list
135-
referencingWorkloads, err := r.findReferencingWorkloads(ctx, authzConfig)
136-
if err != nil {
137-
logger.Error(err, "Failed to find referencing workloads")
138-
} else if !ctrlutil.WorkloadRefsEqual(authzConfig.Status.ReferencingWorkloads, referencingWorkloads) {
139138
authzConfig.Status.ReferencingWorkloads = referencingWorkloads
140-
conditionChanged = true
141-
}
142139

143-
// Update condition if it changed (even without hash change)
144-
if conditionChanged {
145140
if err := r.Status().Update(ctx, authzConfig); err != nil {
146-
logger.Error(err, "Failed to update MCPAuthzConfig status after condition change")
141+
logger.Error(err, "Failed to update MCPAuthzConfig status")
147142
return ctrl.Result{}, err
148143
}
149144
}
@@ -153,6 +148,9 @@ func (r *MCPAuthzConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reque
153148

154149
// validateAuthzConfigSpec validates the MCPAuthzConfig spec by reconstructing the
155150
// full authorizer config and delegating to the factory's ValidateConfig method.
151+
// Unlike other config CRDs (MCPOIDCConfig, MCPTelemetryConfig), validation lives here
152+
// rather than as a Validate() method on the type because it requires the authorizer
153+
// factory registry — an external dependency that the API types package should not import.
156154
func validateAuthzConfigSpec(spec mcpv1alpha1.MCPAuthzConfigSpec) error {
157155
fullConfigJSON, err := BuildFullAuthzConfigJSON(spec)
158156
if err != nil {
@@ -195,9 +193,22 @@ func BuildFullAuthzConfigJSON(spec mcpv1alpha1.MCPAuthzConfigSpec) ([]byte, erro
195193

196194
configKey := factory.ConfigKey()
197195

196+
if len(spec.Config.Raw) == 0 {
197+
return nil, fmt.Errorf("config field is empty")
198+
}
199+
200+
versionJSON, err := marshalJSON(authzConfigVersion)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to marshal version: %w", err)
203+
}
204+
typeJSON, err := marshalJSON(spec.Type)
205+
if err != nil {
206+
return nil, fmt.Errorf("failed to marshal type: %w", err)
207+
}
208+
198209
fullConfig := map[string]json.RawMessage{
199-
"version": mustMarshalJSON(authzConfigVersion),
200-
"type": mustMarshalJSON(spec.Type),
210+
"version": versionJSON,
211+
"type": typeJSON,
201212
configKey: spec.Config.Raw,
202213
}
203214

@@ -208,13 +219,13 @@ func BuildFullAuthzConfigJSON(spec mcpv1alpha1.MCPAuthzConfigSpec) ([]byte, erro
208219
return result, nil
209220
}
210221

211-
// mustMarshalJSON marshals a value to JSON or panics. Only for simple string values.
212-
func mustMarshalJSON(v string) json.RawMessage {
222+
// marshalJSON marshals a string value to JSON, returning an error instead of panicking.
223+
func marshalJSON(v string) (json.RawMessage, error) {
213224
b, err := json.Marshal(v)
214225
if err != nil {
215-
panic(fmt.Sprintf("failed to marshal %q: %v", v, err))
226+
return nil, fmt.Errorf("failed to marshal %q: %w", v, err)
216227
}
217-
return b
228+
return b, nil
218229
}
219230

220231
// handleDeletion handles the deletion of a MCPAuthzConfig.

cmd/thv-operator/controllers/mcpauthzconfig_controller_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,75 @@ func TestMCPAuthzConfigReconciler_handleDeletion(t *testing.T) {
399399
Namespace: "default",
400400
},
401401
Spec: mcpv1alpha1.MCPServerSpec{
402+
Image: "example/mcp:latest",
403+
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
404+
Name: "test-config",
405+
},
406+
},
407+
},
408+
},
409+
expectFinalizerRemoved: false,
410+
expectRequeue: true,
411+
},
412+
{
413+
name: "referencing VirtualMCPServer blocks deletion",
414+
authzConfig: &mcpv1alpha1.MCPAuthzConfig{
415+
ObjectMeta: metav1.ObjectMeta{
416+
Name: "test-config",
417+
Namespace: "default",
418+
Finalizers: []string{AuthzConfigFinalizerName},
419+
DeletionTimestamp: &metav1.Time{
420+
Time: time.Now(),
421+
},
422+
},
423+
Spec: mcpv1alpha1.MCPAuthzConfigSpec{
424+
Type: "cedarv1",
425+
Config: validCedarConfig(),
426+
},
427+
},
428+
existingWorkloads: []client.Object{
429+
&mcpv1alpha1.VirtualMCPServer{
430+
ObjectMeta: metav1.ObjectMeta{
431+
Name: "referencing-vmcp",
432+
Namespace: "default",
433+
},
434+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
435+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
436+
Type: "anonymous",
437+
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
438+
Name: "test-config",
439+
},
440+
},
441+
},
442+
},
443+
},
444+
expectFinalizerRemoved: false,
445+
expectRequeue: true,
446+
},
447+
{
448+
name: "referencing MCPRemoteProxy blocks deletion",
449+
authzConfig: &mcpv1alpha1.MCPAuthzConfig{
450+
ObjectMeta: metav1.ObjectMeta{
451+
Name: "test-config",
452+
Namespace: "default",
453+
Finalizers: []string{AuthzConfigFinalizerName},
454+
DeletionTimestamp: &metav1.Time{
455+
Time: time.Now(),
456+
},
457+
},
458+
Spec: mcpv1alpha1.MCPAuthzConfigSpec{
459+
Type: "cedarv1",
460+
Config: validCedarConfig(),
461+
},
462+
},
463+
existingWorkloads: []client.Object{
464+
&mcpv1alpha1.MCPRemoteProxy{
465+
ObjectMeta: metav1.ObjectMeta{
466+
Name: "referencing-proxy",
467+
Namespace: "default",
468+
},
469+
Spec: mcpv1alpha1.MCPRemoteProxySpec{
470+
RemoteURL: "https://example.com",
402471
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
403472
Name: "test-config",
404473
},
@@ -614,3 +683,155 @@ func TestMCPAuthzConfigReconciler_ValidationRecovery(t *testing.T) {
614683
assert.True(t, foundTrue, "Should have Valid=True condition after fix")
615684
assert.NotEmpty(t, recoveredConfig.Status.ConfigHash, "Hash should be set after recovery")
616685
}
686+
687+
func TestMCPAuthzConfigReconciler_findReferencingWorkloads(t *testing.T) {
688+
t.Parallel()
689+
690+
tests := []struct {
691+
name string
692+
authzConfigName string
693+
existingWorkloads []client.Object
694+
expectedRefs []mcpv1alpha1.WorkloadReference
695+
expectEmpty bool
696+
}{
697+
{
698+
name: "all three workload types referencing the same config",
699+
authzConfigName: "shared-config",
700+
existingWorkloads: []client.Object{
701+
&mcpv1alpha1.MCPServer{
702+
ObjectMeta: metav1.ObjectMeta{
703+
Name: "my-server",
704+
Namespace: "default",
705+
},
706+
Spec: mcpv1alpha1.MCPServerSpec{
707+
Image: "example/mcp:latest",
708+
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
709+
Name: "shared-config",
710+
},
711+
},
712+
},
713+
&mcpv1alpha1.VirtualMCPServer{
714+
ObjectMeta: metav1.ObjectMeta{
715+
Name: "my-vmcp",
716+
Namespace: "default",
717+
},
718+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
719+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
720+
Type: "anonymous",
721+
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
722+
Name: "shared-config",
723+
},
724+
},
725+
},
726+
},
727+
&mcpv1alpha1.MCPRemoteProxy{
728+
ObjectMeta: metav1.ObjectMeta{
729+
Name: "my-proxy",
730+
Namespace: "default",
731+
},
732+
Spec: mcpv1alpha1.MCPRemoteProxySpec{
733+
RemoteURL: "https://example.com",
734+
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
735+
Name: "shared-config",
736+
},
737+
},
738+
},
739+
},
740+
expectedRefs: []mcpv1alpha1.WorkloadReference{
741+
{Kind: mcpv1alpha1.WorkloadKindMCPRemoteProxy, Name: "my-proxy"},
742+
{Kind: mcpv1alpha1.WorkloadKindMCPServer, Name: "my-server"},
743+
{Kind: mcpv1alpha1.WorkloadKindVirtualMCPServer, Name: "my-vmcp"},
744+
},
745+
},
746+
{
747+
name: "no workloads reference the config",
748+
authzConfigName: "unused-config",
749+
existingWorkloads: []client.Object{
750+
&mcpv1alpha1.MCPServer{
751+
ObjectMeta: metav1.ObjectMeta{
752+
Name: "unrelated-server",
753+
Namespace: "default",
754+
},
755+
Spec: mcpv1alpha1.MCPServerSpec{
756+
Image: "example/mcp:latest",
757+
AuthzConfigRef: &mcpv1alpha1.MCPAuthzConfigReference{
758+
Name: "other-config",
759+
},
760+
},
761+
},
762+
&mcpv1alpha1.VirtualMCPServer{
763+
ObjectMeta: metav1.ObjectMeta{
764+
Name: "unrelated-vmcp",
765+
Namespace: "default",
766+
},
767+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
768+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
769+
Type: "anonymous",
770+
},
771+
},
772+
},
773+
&mcpv1alpha1.MCPRemoteProxy{
774+
ObjectMeta: metav1.ObjectMeta{
775+
Name: "unrelated-proxy",
776+
Namespace: "default",
777+
},
778+
Spec: mcpv1alpha1.MCPRemoteProxySpec{
779+
RemoteURL: "https://example.com",
780+
},
781+
},
782+
},
783+
expectEmpty: true,
784+
},
785+
}
786+
787+
for _, tt := range tests {
788+
t.Run(tt.name, func(t *testing.T) {
789+
t.Parallel()
790+
791+
ctx := t.Context()
792+
793+
scheme := runtime.NewScheme()
794+
require.NoError(t, mcpv1alpha1.AddToScheme(scheme))
795+
796+
fakeClient := fake.NewClientBuilder().
797+
WithScheme(scheme).
798+
WithObjects(tt.existingWorkloads...).
799+
Build()
800+
801+
r := &MCPAuthzConfigReconciler{
802+
Client: fakeClient,
803+
Scheme: scheme,
804+
}
805+
806+
authzConfig := &mcpv1alpha1.MCPAuthzConfig{
807+
ObjectMeta: metav1.ObjectMeta{
808+
Name: tt.authzConfigName,
809+
Namespace: "default",
810+
},
811+
}
812+
813+
refs, err := r.findReferencingWorkloads(ctx, authzConfig)
814+
require.NoError(t, err)
815+
816+
if tt.expectEmpty {
817+
assert.Empty(t, refs)
818+
} else {
819+
assert.Equal(t, tt.expectedRefs, refs)
820+
}
821+
})
822+
}
823+
}
824+
825+
func TestBuildFullAuthzConfigJSON_EmptyConfigRaw(t *testing.T) {
826+
t.Parallel()
827+
828+
spec := mcpv1alpha1.MCPAuthzConfigSpec{
829+
Type: "cedarv1",
830+
Config: runtime.RawExtension{Raw: []byte{}},
831+
}
832+
833+
result, err := BuildFullAuthzConfigJSON(spec)
834+
require.Error(t, err)
835+
assert.Nil(t, result)
836+
assert.Contains(t, err.Error(), "config field is empty")
837+
}

cmd/thv-operator/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import (
3232
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
3333
"github.com/stacklok/toolhive/cmd/thv-operator/controllers"
3434
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
35+
// Import authorizer backends so they register with the factory registry.
36+
// Placed here (not in the controller) to keep the controller backend-agnostic.
37+
_ "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
38+
_ "github.com/stacklok/toolhive/pkg/authz/authorizers/http"
3539
"github.com/stacklok/toolhive/pkg/operator/telemetry"
3640
)
3741

0 commit comments

Comments
 (0)