Skip to content

Commit 321c747

Browse files
JAORMXclaude
andcommitted
Add MCPAuthzConfig CRD for backend-agnostic authz
Introduce a namespace-scoped MCPAuthzConfig CRD so authorization policy can be defined once and shared across MCPServer, MCPRemoteProxy, and VirtualMCPServer workloads, mirroring the existing MCPOIDCConfig sharing pattern. The spec carries a backend Type plus an opaque Config RawExtension. A ConfigKey() method on AuthorizerFactory (cedar->"cedar", http->"pdp") lets the controller reconstruct the full authorizer config and validate it via the factory registry, keeping the controller backend-agnostic; the backends are registered through blank imports in the operator entrypoint. The controller validates the spec, computes a config hash, manages a finalizer, tracks referencing workloads, and blocks deletion while referenced. AuthzConfigRef fields are added to the three workload specs with CEL XValidation enforcing mutual exclusivity against the existing inline authzConfig, matching the oidcConfig/oidcConfigRef pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6f63ac0 commit 321c747

26 files changed

Lines changed: 2818 additions & 31 deletions

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,35 @@ type MCPOIDCConfigList struct {
126126
Items []MCPOIDCConfig `json:"items"`
127127
}
128128

129+
// ─── MCPAuthzConfig ──────────────────────────────────────────────────────────
130+
131+
//+kubebuilder:object:root=true
132+
//+kubebuilder:deprecatedversion:warning="toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1"
133+
//+kubebuilder:subresource:status
134+
//+kubebuilder:resource:shortName=authzcfg,categories=toolhive
135+
//+kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
136+
//+kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
137+
//+kubebuilder:printcolumn:name="References",type=integer,JSONPath=`.status.referenceCount`
138+
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
139+
140+
// MCPAuthzConfig is the deprecated v1alpha1 version of the MCPAuthzConfig resource.
141+
type MCPAuthzConfig struct {
142+
metav1.TypeMeta `json:",inline"` // nolint:revive
143+
metav1.ObjectMeta `json:"metadata,omitempty"`
144+
145+
Spec v1beta1.MCPAuthzConfigSpec `json:"spec,omitempty"`
146+
Status v1beta1.MCPAuthzConfigStatus `json:"status,omitempty"`
147+
}
148+
149+
//+kubebuilder:object:root=true
150+
151+
// MCPAuthzConfigList contains a list of MCPAuthzConfig.
152+
type MCPAuthzConfigList struct {
153+
metav1.TypeMeta `json:",inline"` // nolint:revive
154+
metav1.ListMeta `json:"metadata,omitempty"`
155+
Items []MCPAuthzConfig `json:"items"`
156+
}
157+
129158
// ─── MCPRegistry ─────────────────────────────────────────────────────────────
130159

131160
//+kubebuilder:object:root=true
@@ -397,6 +426,7 @@ type VirtualMCPServerList struct {
397426
func init() {
398427
SchemeBuilder.Register(
399428
&EmbeddingServer{}, &EmbeddingServerList{},
429+
&MCPAuthzConfig{}, &MCPAuthzConfigList{},
400430
&MCPExternalAuthConfig{}, &MCPExternalAuthConfigList{},
401431
&MCPGroup{}, &MCPGroupList{},
402432
&MCPOIDCConfig{}, &MCPOIDCConfigList{},

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package v1beta1
5+
6+
import (
7+
"fmt"
8+
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
runtime "k8s.io/apimachinery/pkg/runtime"
11+
)
12+
13+
// Condition type and reasons for MCPAuthzConfig status (RFC-0023)
14+
const (
15+
// ConditionTypeAuthzConfigValid indicates whether the MCPAuthzConfig configuration is valid
16+
ConditionTypeAuthzConfigValid = ConditionTypeValid
17+
18+
// ConditionReasonAuthzConfigValid indicates spec validation passed
19+
ConditionReasonAuthzConfigValid = "ConfigValid"
20+
21+
// ConditionReasonAuthzConfigInvalid indicates spec validation failed
22+
ConditionReasonAuthzConfigInvalid = "ConfigInvalid"
23+
)
24+
25+
// MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig.
26+
// MCPAuthzConfig resources are namespace-scoped and can only be referenced by
27+
// MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace.
28+
type MCPAuthzConfigSpec struct {
29+
// Type identifies the authorizer backend (e.g., "cedarv1", "httpv1").
30+
// Must match a registered authorizer type in the factory registry.
31+
// +kubebuilder:validation:Required
32+
// +kubebuilder:validation:MinLength=1
33+
Type string `json:"type"`
34+
35+
// Config contains the backend-specific authorization configuration.
36+
// The structure depends on the Type field:
37+
// - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string)
38+
// - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string)
39+
// +kubebuilder:pruning:PreserveUnknownFields
40+
// +kubebuilder:validation:Type=object
41+
Config runtime.RawExtension `json:"config"`
42+
}
43+
44+
// MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig
45+
type MCPAuthzConfigStatus struct {
46+
// Conditions represent the latest available observations of the MCPAuthzConfig's state
47+
// +listType=map
48+
// +listMapKey=type
49+
// +optional
50+
Conditions []metav1.Condition `json:"conditions,omitempty"`
51+
52+
// ObservedGeneration is the most recent generation observed for this MCPAuthzConfig.
53+
// +optional
54+
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
55+
56+
// ConfigHash is a hash of the current configuration for change detection
57+
// +optional
58+
ConfigHash string `json:"configHash,omitempty"`
59+
60+
// ReferenceCount is the number of workloads referencing this config.
61+
// +optional
62+
ReferenceCount int32 `json:"referenceCount,omitempty"`
63+
64+
// ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig.
65+
// Each entry identifies the workload by kind and name.
66+
// +listType=map
67+
// +listMapKey=name
68+
// +optional
69+
ReferencingWorkloads []WorkloadReference `json:"referencingWorkloads,omitempty"`
70+
}
71+
72+
// +kubebuilder:object:root=true
73+
// +kubebuilder:storageversion
74+
// +kubebuilder:subresource:status
75+
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
76+
// +kubebuilder:resource:shortName=authzcfg,categories=toolhive
77+
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
78+
// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
79+
// +kubebuilder:printcolumn:name="References",type=integer,JSONPath=`.status.referenceCount`
80+
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
81+
82+
// MCPAuthzConfig is the Schema for the mcpauthzconfigs API.
83+
// MCPAuthzConfig resources are namespace-scoped and can only be referenced by
84+
// MCPServer, MCPRemoteProxy, or VirtualMCPServer resources within the same namespace.
85+
// Cross-namespace references are not supported for security and isolation reasons.
86+
type MCPAuthzConfig struct {
87+
metav1.TypeMeta `json:",inline"` // nolint:revive
88+
metav1.ObjectMeta `json:"metadata,omitempty"`
89+
90+
Spec MCPAuthzConfigSpec `json:"spec,omitempty"`
91+
Status MCPAuthzConfigStatus `json:"status,omitempty"`
92+
}
93+
94+
// +kubebuilder:object:root=true
95+
96+
// MCPAuthzConfigList contains a list of MCPAuthzConfig
97+
type MCPAuthzConfigList struct {
98+
metav1.TypeMeta `json:",inline"` // nolint:revive
99+
metav1.ListMeta `json:"metadata,omitempty"`
100+
Items []MCPAuthzConfig `json:"items"`
101+
}
102+
103+
// MCPAuthzConfigReference references a shared MCPAuthzConfig resource.
104+
// The referenced MCPAuthzConfig must be in the same namespace as the referencing workload.
105+
type MCPAuthzConfigReference struct {
106+
// Name is the name of the MCPAuthzConfig resource in the same namespace.
107+
// +kubebuilder:validation:Required
108+
// +kubebuilder:validation:MinLength=1
109+
Name string `json:"name"`
110+
}
111+
112+
// Validate performs structural validation on the MCPAuthzConfig spec.
113+
// This method is called by the controller during reconciliation.
114+
//
115+
// Note: This provides defense-in-depth alongside CEL validation rules. CEL catches
116+
// issues at API admission time, but this method also validates stored objects to
117+
// catch any that bypassed CEL or were stored before CEL rules were added.
118+
//
119+
// Backend-specific validation (delegating to the authorizer factory registry) lives
120+
// in the controller rather than here, because the API types package must not import
121+
// the authorizer backends.
122+
func (r *MCPAuthzConfig) Validate() error {
123+
if r.Spec.Type == "" {
124+
return fmt.Errorf("type must not be empty")
125+
}
126+
if len(r.Spec.Config.Raw) == 0 {
127+
return fmt.Errorf("config must not be empty")
128+
}
129+
return nil
130+
}
131+
132+
func init() {
133+
SchemeBuilder.Register(&MCPAuthzConfig{}, &MCPAuthzConfigList{})
134+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package v1beta1
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
runtime "k8s.io/apimachinery/pkg/runtime"
11+
)
12+
13+
func TestMCPAuthzConfig_Validate(t *testing.T) {
14+
t.Parallel()
15+
16+
tests := []struct {
17+
name string
18+
spec MCPAuthzConfigSpec
19+
expectError bool
20+
}{
21+
{
22+
name: "valid spec passes",
23+
spec: MCPAuthzConfigSpec{
24+
Type: "cedarv1",
25+
Config: runtime.RawExtension{Raw: []byte(`{"policies":[]}`)},
26+
},
27+
expectError: false,
28+
},
29+
{
30+
name: "empty type fails",
31+
spec: MCPAuthzConfigSpec{
32+
Type: "",
33+
Config: runtime.RawExtension{Raw: []byte(`{}`)},
34+
},
35+
expectError: true,
36+
},
37+
{
38+
name: "empty config raw fails",
39+
spec: MCPAuthzConfigSpec{
40+
Type: "cedarv1",
41+
Config: runtime.RawExtension{Raw: []byte{}},
42+
},
43+
expectError: true,
44+
},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
t.Parallel()
50+
51+
cfg := &MCPAuthzConfig{Spec: tt.spec}
52+
err := cfg.Validate()
53+
if tt.expectError {
54+
assert.Error(t, err)
55+
} else {
56+
assert.NoError(t, err)
57+
}
58+
})
59+
}
60+
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ type HeaderFromSecret struct {
3636
}
3737

3838
// MCPRemoteProxySpec defines the desired state of MCPRemoteProxy
39+
//
40+
// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"
41+
//
42+
//nolint:lll // CEL validation rules exceed line length limit
3943
type MCPRemoteProxySpec struct {
4044
// RemoteURL is the URL of the remote MCP server to proxy
4145
// +kubebuilder:validation:Required
@@ -77,10 +81,18 @@ type MCPRemoteProxySpec struct {
7781
// +optional
7882
HeaderForward *HeaderForwardConfig `json:"headerForward,omitempty"`
7983

80-
// AuthzConfig defines authorization policy configuration for the proxy
84+
// AuthzConfig defines authorization policy configuration for the proxy.
85+
// Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead.
86+
// AuthzConfig and AuthzConfigRef are mutually exclusive.
8187
// +optional
8288
AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"`
8389

90+
// AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
91+
// The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy.
92+
// Mutually exclusive with authzConfig.
93+
// +optional
94+
AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"`
95+
8496
// Audit defines audit logging configuration for the proxy
8597
// +optional
8698
Audit *AuditConfig `json:"audit,omitempty"`

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ const SessionStorageProviderRedis = "redis"
200200

201201
// MCPServerSpec defines the desired state of MCPServer
202202
//
203+
// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"
203204
// +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="rateLimiting requires sessionStorage with provider 'redis'"
204205
// +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)"
205206
// +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 {
293294
// +optional
294295
OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"`
295296

296-
// AuthzConfig defines authorization policy configuration for the MCP server
297+
// AuthzConfig defines authorization policy configuration for the MCP server.
298+
// Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead.
299+
// AuthzConfig and AuthzConfigRef are mutually exclusive.
297300
// +optional
298301
AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"`
299302

303+
// AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
304+
// The referenced MCPAuthzConfig must exist in the same namespace as this MCPServer.
305+
// Mutually exclusive with authzConfig.
306+
// +optional
307+
AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"`
308+
300309
// Audit defines audit logging configuration for the MCP server
301310
// +optional
302311
Audit *AuditConfig `json:"audit,omitempty"`

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ type EmbeddingServerRef struct {
160160
// IncomingAuthConfig configures authentication for clients connecting to the Virtual MCP server
161161
//
162162
// +kubebuilder:validation:XValidation:rule="self.type == 'oidc' ? has(self.oidcConfigRef) : true",message="spec.incomingAuth.oidcConfigRef is required when type is oidc"
163+
// +kubebuilder:validation:XValidation:rule="!(has(self.authzConfig) && has(self.authzConfigRef))",message="authzConfig and authzConfigRef are mutually exclusive; use authzConfigRef to reference a shared MCPAuthzConfig"
163164
//
164165
//nolint:lll // CEL validation rules exceed line length limit
165166
type IncomingAuthConfig struct {
@@ -176,10 +177,18 @@ type IncomingAuthConfig struct {
176177
// +optional
177178
OIDCConfigRef *MCPOIDCConfigReference `json:"oidcConfigRef,omitempty"`
178179

179-
// AuthzConfig defines authorization policy configuration
180-
// Reuses MCPServer authz patterns
180+
// AuthzConfig defines authorization policy configuration.
181+
// Reuses MCPServer authz patterns.
182+
// Deprecated: Use AuthzConfigRef to reference a shared MCPAuthzConfig resource instead.
183+
// AuthzConfig and AuthzConfigRef are mutually exclusive.
181184
// +optional
182185
AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"`
186+
187+
// AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
188+
// The referenced MCPAuthzConfig must exist in the same namespace as this VirtualMCPServer.
189+
// Mutually exclusive with authzConfig.
190+
// +optional
191+
AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"`
183192
}
184193

185194
// OutgoingAuthConfig configures authentication from Virtual MCP to backend MCPServers

0 commit comments

Comments
 (0)