Skip to content

Commit 757a6b5

Browse files
JAORMXclaudeChrisJBurnsjhrozek
authored
Add MCPAuthzConfig CRD for backend-agnostic authorization (#4777)
* 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> * Add mcpauthzconfigs to RBAC E2E golden fixtures The chainsaw operator RBAC assertions compare the live toolhive-operator-manager-role ClusterRole against a hardcoded expected manifest. Adding the MCPAuthzConfig CRD introduced new mcpauthzconfigs rules in the generated ClusterRole, making the fixtures stale and failing E2E on all kind versions. Add the mcpauthzconfigs, mcpauthzconfigs/finalizers, and mcpauthzconfigs/status entries in the same alphabetical position the generated role uses (after embeddingservers*, before mcpexternalauthconfigs*) to both the multi-tenancy and single-tenancy setup fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Guard reserved ConfigKey values and tighten MCPAuthzConfig.Validate Register() now panics if a factory's ConfigKey() returns one of the reserved envelope keys ("", "version", "type"). BuildFullAuthzConfigJSON assembles its config with a Go map literal where those two keys are fixed metadata; a factory returning a reserved key would silently overwrite the envelope. Catching this at startup makes the misconfiguration impossible to ship. MCPAuthzConfig.Validate() now consults authorizers.IsRegistered so any caller (CLI tooling, future webhook, defense-in-depth fallback) fails closed on an unknown spec.type rather than deferring entirely to the controller. The unit tests blank-import the cedar backend so the existing "cedarv1" cases continue to pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Canonicalize MCPAuthzConfig spec before hashing The config hash is computed via ctrlutil.CalculateConfigHash, which walks the spec struct including Spec.Config.Raw. RawExtension preserves the user's raw bytes verbatim, so two semantically-equal configs that differ only in whitespace or JSON key order produced different hashes and flipped status on every noop kubectl-apply round-trip. canonicalizeSpecForHash returns a copy of the spec whose Config.Raw has been re-marshalled (Go's encoding/json sorts map keys), giving a stable hash for the same logical config regardless of source formatting. Malformed JSON is returned unchanged so Validate / validateAuthzConfig remain the source of the user-facing error. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use MutateAndPatchStatus/Spec in MCPAuthzConfig controller Status writes now flow through controllerutil.MutateAndPatchStatus and finalizer writes through controllerutil.MutateAndPatchSpec, per the operator rule (.claude/rules/operator.md). The previous r.Status().Update calls sent full PUT bodies that would clobber conditions written by any disjoint owner of Status.Conditions on this CRD; the previous r.Update calls had no optimistic-lock guard around finalizer arrays. Condition and field mutations have moved inside the helper closures so the pre-mutate snapshot reflects the live state rather than already containing the change — a MutateAndPatchStatus prerequisite. A small helper, setValidTrueCondition, factors out the Valid=True transition so the success path doesn't duplicate the metav1.Condition literal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Tighten MCPAuthzConfig reconcile flow Collapses the previously-separate hash-change branch and steady-state status update into a single MutateAndPatchStatus call on the success path. Side effects of doing so: - F4: ReferencingWorkloads / ReferenceCount now refresh in the same reconcile that writes a new ConfigHash, so the print column doesn't lag a workload event behind. - F8: The success path explicitly removes the DeletionBlocked condition. A user who cancels a deletion (e.g., by removing the finalizer manually after observing the block) no longer carries a stale DeletionBlocked=True forward across reconciles. - F9: findReferencingWorkloads errors now return up to controller- runtime instead of being logged and swallowed, so a transient apiserver hiccup is retried with backoff rather than silently writing an empty references list. Two new tests exercise the F4 (one-reconcile property) and F8 (condition-clear) paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Unexport buildFullAuthzConfigJSON No out-of-package caller exists for this helper today. PR #4778 will need it once workload controllers resolve AuthzConfigRef, and the cleaner home at that point will be cmd/thv-operator/pkg/controllerutil/ alongside BuildInlineCedarAuthzConfig. Until then keep the signature package-private so the contract can evolve without breaking external consumers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Declare mcpservers RBAC marker on MCPAuthzConfig controller findReferencingWorkloads lists MCPServer alongside VirtualMCPServer and MCPRemoteProxy, but the kubebuilder marker only declared the latter two. The clusterrole currently works because mcpserver_controller's markers cover get;list;watch on mcpservers, so the rendered output is unchanged here — but a future regeneration that reshuffles those declarations could silently strip the permission the MCPAuthzConfig controller actually depends on. Verified the rendered clusterrole and chainsaw asserts are unchanged after task operator-manifests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Test condition preservation and last-ref finalizer transition Two new tests for MCPAuthzConfig reconciler coverage: - FinalizerRemovedAfterLastRefDropped: exercises the N→0 transition in handleDeletion. The existing static-state cases cover "blocked" and "no refs" endpoints but not the flip between them, which is the user-observed behaviour for policy rotation. - PreservesForeignConditions: the canary for the merge-patch conditions-array semantic. JSON merge-patch on CRDs replaces the conditions array wholesale (the +listType=map marker only matters to strategic-merge-patch), so a regression that re-introduces r.Status().Update on this resource — or that pre-mutates Status outside a MutateAndPatchStatus closure — would erase any concurrently- set foreign condition. This test fails in that scenario. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add envtest for authzConfig/authzConfigRef mutual exclusion CEL XValidation rules run at API admission, which unit tests with the fake client cannot exercise. The three workload specs (MCPServer, MCPRemoteProxy, VirtualMCPServer.IncomingAuth) each declare the same mutex rule between the legacy inline authzConfig and the new authzConfigRef — without envtest coverage the rules could silently regress on a CRD regeneration. For each spec, the new tests apply only-inline, only-ref, and both-set CRs and assert (c) is rejected with the expected message. They follow the existing pattern in cmd/thv-operator/test-integration/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Reframe AuthzConfigRef as part 1 of 2 in godocs The new AuthzConfigRef field on MCPServer, MCPRemoteProxy, and VirtualMCPServer.IncomingAuth is wired into the MCPAuthzConfig controller's reference tracking (status.referenceCount, deletion protection) but no workload controller actually resolves the ref into a runtime authz config in this PR. That wiring lands in a follow-up. The previous godocs marked the inline AuthzConfig field "Deprecated: Use AuthzConfigRef" — which pointed users at a non-functional field and risked workloads running with no authorization enforced. Drop the premature Deprecated annotation and add an explicit NOTE on AuthzConfigRef so adopters know to stick with the inline form until the consumer-side wiring lands. The CEL mutex rule and the controller's reference tracking are unchanged; only the descriptive godocs move. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Regenerate CRD manifests and reference docs Picks up the F1 godoc edits: the CRD YAML descriptions and the generated CRD API reference (docs/operator/crd-api.md) now match the authoritative godoc on AuthzConfigRef / AuthzConfig (no premature Deprecated annotation; explicit "consumed in a follow-up PR" note on AuthzConfigRef). Generated by task operator-generate + task operator-manifests + task crdref-gen. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use compound (kind, name) listMapKey on ReferencingWorkloads The original review of this PR flagged that +listMapKey=name alone is insufficient: two workloads of different kinds that share a name (an MCPServer "foo" and a VirtualMCPServer "foo") would collide under merge-patch semantics, with the second-applied entry silently overwriting the first. Adding +listMapKey=kind makes the map key the (kind, name) pair so cross-kind name reuse stays distinct. Limited to MCPAuthzConfig here. The two sibling controllers (MCPOIDCConfig, MCPExternalAuthConfig) share the same WorkloadReference type and have the same listMapKey=name asymmetry — filed as a parity follow-up rather than expanding this PR's scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Refresh validateAuthzConfig doc comment The prior comment said backend validation lived in the controller "because the API types package must not import the authorizer backends" — but the types package now imports pkg/authz/authorizers to call IsRegistered in Validate(). Restate the real reason: type-level Validate handles structural + registration checks; backend ValidateConfig requires the full reconstructed JSON envelope that only the controller builds. Drive-by: gitignore .pr-review/ so PR-review skill scratch dirs don't pollute git status in worktrees. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Thread factory through buildFullAuthzConfigJSON, drop redundant lookups buildFullAuthzConfigJSON now returns the resolved AuthorizerFactory alongside the envelope JSON, so validateAuthzConfig can dispatch ValidateConfig directly without a second GetFactory call and without re-Unmarshalling the JSON it just built. Before: Validate() ran IsRegistered, buildFullAuthzConfigJSON ran GetFactory, validateAuthzConfig re-Unmarshalled the envelope and ran GetFactory again — three near-identical lookups for the same key on every reconcile, with three slightly different error messages. The authzConfigEnvelope struct is removed; nothing else consumed it. Tests updated to assert the returned factory's ConfigKey matches the expected nested envelope key, locking in the bidirectional contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Drop marshalJSONString in favor of strconv.Quote json.Marshal of a Go string cannot fail at runtime — encoding/json's stringEncoder has no error path for valid Go strings. The helper existed to placate errcheck rather than to catch a real failure mode, adding two unreachable error branches per call site. strconv.Quote produces the same JSON-encoded bytes with no error-checking overhead, and the call sites become readable single expressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Demote hash-change log to DEBUG Silent-success per .claude/rules/go-style.md: routine state transitions log at DEBUG, INFO is reserved for long-running operations. A noisy INFO on every kubectl-apply that bumps a hash is exactly the case the rule was written to prevent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Mark AuthzConfigRef staging NOTE with TODO(#4778) The staging note on AuthzConfigRef becomes inaccurate the moment the workload-controller wiring lands. A grep-able TODO(#4778) marker in the Go source ensures whoever lands #4778 finds the stale text without manually scanning three type files. controller-gen strips TODO lines from the rendered CRD description, so the user-facing schema stays clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Lock ConfigKey()/struct tag contract per backend Factory.ConfigKey() returns a string ("cedar" / "pdp") that must match the json tag on the backend's Config.Options field — otherwise the controller will emit envelope JSON the backend's own Unmarshal cannot parse. A new TestFactory_ConfigKeyMatchesStructTag per backend builds an envelope around ConfigKey() and asserts it deserialises with Options populated. A future rename of either string in isolation will fail the test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Tighten test docstrings, fixtures, and assertions PreservesForeignConditions: docstring reframed to describe the property it actually guards (snapshot-and-diff doesn't erase foreign conditions during patch construction) and to be explicit about what it does NOT catch (r.Status().Update under the fake client — requires a WithInterceptorFuncs-backed concurrent-writer scenario). HashAndRefsLandInOneReconcile: the MCPServer fixture now sets the required Image field so the test remains portable to envtest. Added an ObservedGeneration assertion so all four fields written in the single MutateAndPatchStatus closure are pinned (previously hash, references, referencingWorkloads.name were locked but ObservedGeneration was not). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Update cmd/thv-operator/controllers/mcpauthzconfig_controller.go Co-authored-by: Jakub Hrozek <jakub.hrozek@posteo.se> * Remove unused strconv import Cascade fix from the web-UI edit that replaced strconv.Quote with json.Marshal: the import was left in place, breaking compilation and every downstream CI job (lint, tests, govulncheck x2, helm lint, e2e lifecycle x3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Co-authored-by: Jakub Hrozek <jakub.hrozek@posteo.se>
1 parent 2ccfae9 commit 757a6b5

34 files changed

Lines changed: 3552 additions & 32 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ cmd/vmcp/__debug_bin*
5050

5151
# Superpowers planning artifacts
5252
docs/superpowers/
53+
.pr-review/

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
// MCPRegistry is the deprecated v1alpha1 version of the MCPRegistry resource.
@@ -402,6 +431,7 @@ type VirtualMCPServerList struct {
402431
func init() {
403432
SchemeBuilder.Register(
404433
&EmbeddingServer{}, &EmbeddingServerList{},
434+
&MCPAuthzConfig{}, &MCPAuthzConfigList{},
405435
&MCPExternalAuthConfig{}, &MCPExternalAuthConfigList{},
406436
&MCPGroup{}, &MCPGroupList{},
407437
&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: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
"github.com/stacklok/toolhive/pkg/authz/authorizers"
13+
)
14+
15+
// Condition type and reasons for MCPAuthzConfig status (RFC-0023)
16+
const (
17+
// ConditionTypeAuthzConfigValid indicates whether the MCPAuthzConfig configuration is valid
18+
ConditionTypeAuthzConfigValid = ConditionTypeValid
19+
20+
// ConditionReasonAuthzConfigValid indicates spec validation passed
21+
ConditionReasonAuthzConfigValid = "ConfigValid"
22+
23+
// ConditionReasonAuthzConfigInvalid indicates spec validation failed
24+
ConditionReasonAuthzConfigInvalid = "ConfigInvalid"
25+
)
26+
27+
// MCPAuthzConfigSpec defines the desired state of MCPAuthzConfig.
28+
// MCPAuthzConfig resources are namespace-scoped and can only be referenced by
29+
// MCPServer, MCPRemoteProxy, or VirtualMCPServer resources in the same namespace.
30+
type MCPAuthzConfigSpec struct {
31+
// Type identifies the authorizer backend (e.g., "cedarv1", "httpv1").
32+
// Must match a registered authorizer type in the factory registry.
33+
// +kubebuilder:validation:Required
34+
// +kubebuilder:validation:MinLength=1
35+
Type string `json:"type"`
36+
37+
// Config contains the backend-specific authorization configuration.
38+
// The structure depends on the Type field:
39+
// - cedarv1: policies ([]string), entities_json (string), primary_upstream_provider (string), group_claim_name (string)
40+
// - httpv1: http ({url, timeout, insecure_skip_verify}), context ({include_args, include_operation}), claim_mapping (string)
41+
// +kubebuilder:pruning:PreserveUnknownFields
42+
// +kubebuilder:validation:Type=object
43+
Config runtime.RawExtension `json:"config"`
44+
}
45+
46+
// MCPAuthzConfigStatus defines the observed state of MCPAuthzConfig
47+
type MCPAuthzConfigStatus struct {
48+
// Conditions represent the latest available observations of the MCPAuthzConfig's state
49+
// +listType=map
50+
// +listMapKey=type
51+
// +optional
52+
Conditions []metav1.Condition `json:"conditions,omitempty"`
53+
54+
// ObservedGeneration is the most recent generation observed for this MCPAuthzConfig.
55+
// +optional
56+
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
57+
58+
// ConfigHash is a hash of the current configuration for change detection
59+
// +optional
60+
ConfigHash string `json:"configHash,omitempty"`
61+
62+
// ReferenceCount is the number of workloads referencing this config.
63+
// +optional
64+
ReferenceCount int32 `json:"referenceCount,omitempty"`
65+
66+
// ReferencingWorkloads is a list of workload resources that reference this MCPAuthzConfig.
67+
// Each entry identifies the workload by kind and name. The map key is the
68+
// (kind, name) pair so two workloads of different kinds that share a name
69+
// (e.g., an MCPServer "foo" and a VirtualMCPServer "foo") are distinct
70+
// entries rather than colliding under merge-patch semantics.
71+
// +listType=map
72+
// +listMapKey=kind
73+
// +listMapKey=name
74+
// +optional
75+
ReferencingWorkloads []WorkloadReference `json:"referencingWorkloads,omitempty"`
76+
}
77+
78+
// +kubebuilder:object:root=true
79+
// +kubebuilder:storageversion
80+
// +kubebuilder:subresource:status
81+
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
82+
// +kubebuilder:resource:shortName=authzcfg,categories=toolhive
83+
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
84+
// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
85+
// +kubebuilder:printcolumn:name="References",type=integer,JSONPath=`.status.referenceCount`
86+
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
87+
88+
// MCPAuthzConfig is the Schema for the mcpauthzconfigs API.
89+
// MCPAuthzConfig resources are namespace-scoped and can only be referenced by
90+
// MCPServer, MCPRemoteProxy, or VirtualMCPServer resources within the same namespace.
91+
// Cross-namespace references are not supported for security and isolation reasons.
92+
type MCPAuthzConfig struct {
93+
metav1.TypeMeta `json:",inline"` // nolint:revive
94+
metav1.ObjectMeta `json:"metadata,omitempty"`
95+
96+
Spec MCPAuthzConfigSpec `json:"spec,omitempty"`
97+
Status MCPAuthzConfigStatus `json:"status,omitempty"`
98+
}
99+
100+
// +kubebuilder:object:root=true
101+
102+
// MCPAuthzConfigList contains a list of MCPAuthzConfig
103+
type MCPAuthzConfigList struct {
104+
metav1.TypeMeta `json:",inline"` // nolint:revive
105+
metav1.ListMeta `json:"metadata,omitempty"`
106+
Items []MCPAuthzConfig `json:"items"`
107+
}
108+
109+
// MCPAuthzConfigReference references a shared MCPAuthzConfig resource.
110+
// The referenced MCPAuthzConfig must be in the same namespace as the referencing workload.
111+
type MCPAuthzConfigReference struct {
112+
// Name is the name of the MCPAuthzConfig resource in the same namespace.
113+
// +kubebuilder:validation:Required
114+
// +kubebuilder:validation:MinLength=1
115+
Name string `json:"name"`
116+
}
117+
118+
// Validate performs structural validation on the MCPAuthzConfig spec.
119+
// This method is called by the controller during reconciliation.
120+
//
121+
// Validate provides defense-in-depth alongside CEL validation rules: CEL catches
122+
// issues at API admission time, but this method also validates stored objects to
123+
// catch any that bypassed CEL or were stored before CEL rules were added.
124+
//
125+
// Validate consults the authorizer factory registry (pkg/authz/authorizers) to
126+
// reject spec.type values that no backend has registered. Backend-specific
127+
// schema validation (interpreting spec.config) still lives in the controller,
128+
// where the registered factory's ValidateConfig is invoked.
129+
func (r *MCPAuthzConfig) Validate() error {
130+
if r.Spec.Type == "" {
131+
return fmt.Errorf("type must not be empty")
132+
}
133+
if !authorizers.IsRegistered(r.Spec.Type) {
134+
return fmt.Errorf(
135+
"type %q is not a registered authorizer backend (registered: %v)",
136+
r.Spec.Type, authorizers.RegisteredTypes(),
137+
)
138+
}
139+
if len(r.Spec.Config.Raw) == 0 {
140+
return fmt.Errorf("config must not be empty")
141+
}
142+
return nil
143+
}
144+
145+
func init() {
146+
SchemeBuilder.Register(&MCPAuthzConfig{}, &MCPAuthzConfigList{})
147+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
// Blank-imported so authorizers.IsRegistered("cedarv1") returns true
13+
// inside Validate(). Without this, every test case using a real backend
14+
// type would hit the unregistered-type branch.
15+
_ "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
16+
)
17+
18+
func TestMCPAuthzConfig_Validate(t *testing.T) {
19+
t.Parallel()
20+
21+
tests := []struct {
22+
name string
23+
spec MCPAuthzConfigSpec
24+
expectError bool
25+
errContains string
26+
}{
27+
{
28+
name: "valid spec passes",
29+
spec: MCPAuthzConfigSpec{
30+
Type: "cedarv1",
31+
Config: runtime.RawExtension{Raw: []byte(`{"policies":[]}`)},
32+
},
33+
expectError: false,
34+
},
35+
{
36+
name: "empty type fails",
37+
spec: MCPAuthzConfigSpec{
38+
Type: "",
39+
Config: runtime.RawExtension{Raw: []byte(`{}`)},
40+
},
41+
expectError: true,
42+
errContains: "type must not be empty",
43+
},
44+
{
45+
name: "unregistered type fails",
46+
spec: MCPAuthzConfigSpec{
47+
Type: "nope",
48+
Config: runtime.RawExtension{Raw: []byte(`{"policies":[]}`)},
49+
},
50+
expectError: true,
51+
errContains: `"nope" is not a registered authorizer backend`,
52+
},
53+
{
54+
name: "empty config raw fails",
55+
spec: MCPAuthzConfigSpec{
56+
Type: "cedarv1",
57+
Config: runtime.RawExtension{Raw: []byte{}},
58+
},
59+
expectError: true,
60+
errContains: "config must not be empty",
61+
},
62+
{
63+
name: "nil config raw fails",
64+
spec: MCPAuthzConfigSpec{
65+
Type: "cedarv1",
66+
Config: runtime.RawExtension{Raw: nil},
67+
},
68+
expectError: true,
69+
errContains: "config must not be empty",
70+
},
71+
}
72+
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
t.Parallel()
76+
77+
cfg := &MCPAuthzConfig{Spec: tt.spec}
78+
err := cfg.Validate()
79+
if tt.expectError {
80+
assert.Error(t, err)
81+
if tt.errContains != "" {
82+
assert.ErrorContains(t, err, tt.errContains)
83+
}
84+
} else {
85+
assert.NoError(t, err)
86+
}
87+
})
88+
}
89+
}

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

Lines changed: 21 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
@@ -83,10 +87,26 @@ type MCPRemoteProxySpec struct {
8387
// +optional
8488
HeaderForward *HeaderForwardConfig `json:"headerForward,omitempty"`
8589

86-
// AuthzConfig defines authorization policy configuration for the proxy
90+
// AuthzConfig defines authorization policy configuration for the proxy.
91+
// AuthzConfig and AuthzConfigRef are mutually exclusive.
8792
// +optional
8893
AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"`
8994

95+
// AuthzConfigRef references a shared MCPAuthzConfig resource for authorization.
96+
// The referenced MCPAuthzConfig must exist in the same namespace as this MCPRemoteProxy.
97+
// Mutually exclusive with authzConfig.
98+
//
99+
// TODO(#4778): remove the staging NOTE below once workload controllers
100+
// resolve AuthzConfigRef into a runtime authz config.
101+
//
102+
// NOTE: this field is consumed by workload controllers in a follow-up PR.
103+
// Until that lands, AuthzConfigRef is reference-tracked by the
104+
// MCPAuthzConfig controller (deletion protection, status.referenceCount)
105+
// but does NOT apply authorization to this MCPRemoteProxy. Use the
106+
// inline AuthzConfig field in the meantime.
107+
// +optional
108+
AuthzConfigRef *MCPAuthzConfigReference `json:"authzConfigRef,omitempty"`
109+
90110
// Audit defines audit logging configuration for the proxy
91111
// +optional
92112
Audit *AuditConfig `json:"audit,omitempty"`

0 commit comments

Comments
 (0)