Skip to content

Commit 9afbe4e

Browse files
authored
Add CEL validation rule for RemoteMCPServer with CA Cert and AllowedNamespaces (#1988)
# Description Adds a CEL admission rule on `RemoteMCPServer` that rejects setting both `spec.allowedNamespaces` and `spec.tls.caCertSecretRef`. A `RemoteMCPServer` that pins a CA Secret must be same-namespace-only; one that allows cross-namespace references must not pin a CA Secret. This closes a silent footgun in the cross-namespace + TLS combination without changing any working behavior: the `AllowedNamespaces` field stays, the reconciler is unchanged, and non-CA cross-namespace sharing keeps working exactly as before. # Why When an agent consumes a `RemoteMCPServer` that pins a CA bundle, the translator mounts that Secret onto the **agent's** pod by bare name (`addTLSConfiguration`). A `SecretVolumeSource` has no namespace field — Kubernetes always resolves it in the pod's own namespace, not the `RemoteMCPServer`'s. So if the RMS is referenced cross-namespace, the CA mount either: - **dangles** — the Secret doesn't exist in the agent's namespace and the pod fails to start; or - **silently mounts the wrong CA** — an unrelated Secret of the same name that happens to exist in the agent's namespace is trusted for the upstream, with no error anywhere. Meanwhile the controller validates and hashes the Secret in the `RemoteMCPServer`'s namespace, so it reports `Accepted=true` while the pod mount looks elsewhere. The cert reference itself is structurally same-namespace (`caCertSecretRef` is a bare name with no namespace field, resolved in the owner's namespace by design); the only thing that broke co-location was opening the RMS object up cross-namespace via `allowedNamespaces`. Making the two mutually exclusive removes the unsound combination at admission time rather than letting it surface as a runtime mount failure on the consuming agent. # Validation rule Spec-level `XValidation` on `RemoteMCPServerSpec`: ``` rule: !(has(self.allowedNamespaces) && has(self.allowedNamespaces.from) && (self.allowedNamespaces.from == 'All' || self.allowedNamespaces.from == 'Selector') && has(self.tls) && has(self.tls.caCertSecretRef) && size(self.tls.caCertSecretRef) > 0) message: spec.allowedNamespaces must not permit cross-namespace access (from: All or from: Selector) when spec.tls.caCertSecretRef is set: a pinned CA Secret is mounted onto the consuming agent's pod and Kubernetes resolves it in the agent's namespace, not this RemoteMCPServer's. Use from: Same (the default), or remove the CA Secret reference. ``` The rule only rejects the genuinely-hazardous case — a CA Secret combined with an `allowedNamespaces` that actually permits other namespaces (`from: All` or `from: Selector`). `from: Same` (or omitted/empty, which defaults to same-namespace) is always allowed alongside a CA Secret, since it carries no cross-namespace mount hazard. Regenerated `kagent.dev_remotemcpservers.yaml` and synced the helm CRD copy under `helm/kagent-crds/templates/`. # Tests Added cases to the existing envtest CEL suite (`tlsconfig_cel_test.go`), exercised against a real kube-apiserver: - `allowedNamespaces` `from: All` + `caCertSecretRef` → rejected. - `allowedNamespaces` `from: Selector` + `caCertSecretRef` → rejected. - `allowedNamespaces` `from: Same` + `caCertSecretRef` → accepted (same-namespace, no hazard). - `allowedNamespaces` `from: All`, no TLS → accepted (cross-namespace sharing still works). - `caCertSecretRef`, no `allowedNamespaces` → accepted (same-namespace CA pinning still works). # Compatibility Not a breaking API change — no field is removed and no working configuration changes behavior. The only new effect is at admission: a manifest that combines a CA Secret with a *cross-namespace-permitting* `allowedNamespaces` (`from: All` / `from: Selector`) is now rejected on create/update. That combination was already non-functional (dangling or wrong-CA mount), so this turns a silent runtime failure into an explicit, actionable admission error. Same-namespace configurations (`from: Same` or omitted) are unaffected, and objects already stored are not re-validated until their next write. --------- Signed-off-by: Jeremy Alvis <jeremy.alvis@solo.io>
1 parent feb8cf9 commit 9afbe4e

4 files changed

Lines changed: 116 additions & 0 deletions

File tree

go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ spec:
5959
This follows the Gateway API pattern for cross-namespace route attachments.
6060
If not specified, only Agents in the same namespace can reference this RemoteMCPServer.
6161
See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-route-attachment
62+
63+
A cross-namespace-permitting value (from: All or from: Selector) is
64+
mutually exclusive with spec.tls.caCertSecretRef (enforced by a spec-level
65+
XValidation rule): a pinned CA Secret is mounted onto the consuming agent's
66+
pod by bare name and Kubernetes resolves it in the agent's namespace, not
67+
this RemoteMCPServer's, so a CA-pinning RemoteMCPServer cannot be referenced
68+
cross-namespace. from: Same (the default) is always allowed.
6269
properties:
6370
from:
6471
default: Same
@@ -255,6 +262,15 @@ spec:
255262
TLS opinion contradicts a plaintext URL. Either drop spec.tls, or
256263
use https:// / a scheme-less URL.'
257264
rule: '!self.url.startsWith(''http://'') || !has(self.tls)'
265+
- message: 'spec.allowedNamespaces must not permit cross-namespace access
266+
(from: All or from: Selector) when spec.tls.caCertSecretRef is set:
267+
a pinned CA Secret is mounted onto the consuming agent''s pod and
268+
Kubernetes resolves it in the agent''s namespace, not this RemoteMCPServer''s.
269+
Use from: Same (the default), or remove the CA Secret reference.'
270+
rule: '!(has(self.allowedNamespaces) && has(self.allowedNamespaces.from)
271+
&& (self.allowedNamespaces.from == ''All'' || self.allowedNamespaces.from
272+
== ''Selector'') && has(self.tls) && has(self.tls.caCertSecretRef)
273+
&& size(self.tls.caCertSecretRef) > 0)'
258274
status:
259275
description: RemoteMCPServerStatus defines the observed state of RemoteMCPServer.
260276
properties:

go/api/v1alpha2/remotemcpserver_types.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
// RemoteMCPServerSpec defines the desired state of RemoteMCPServer.
4040
//
4141
// +kubebuilder:validation:XValidation:message="spec.tls must be unset when spec.url has http:// scheme: a TLS opinion contradicts a plaintext URL. Either drop spec.tls, or use https:// / a scheme-less URL.",rule="!self.url.startsWith('http://') || !has(self.tls)"
42+
// +kubebuilder:validation:XValidation:message="spec.allowedNamespaces must not permit cross-namespace access (from: All or from: Selector) when spec.tls.caCertSecretRef is set: a pinned CA Secret is mounted onto the consuming agent's pod and Kubernetes resolves it in the agent's namespace, not this RemoteMCPServer's. Use from: Same (the default), or remove the CA Secret reference.",rule="!(has(self.allowedNamespaces) && has(self.allowedNamespaces.from) && (self.allowedNamespaces.from == 'All' || self.allowedNamespaces.from == 'Selector') && has(self.tls) && has(self.tls.caCertSecretRef) && size(self.tls.caCertSecretRef) > 0)"
4243
type RemoteMCPServerSpec struct {
4344
// +required
4445
Description string `json:"description"`
@@ -63,6 +64,13 @@ type RemoteMCPServerSpec struct {
6364
// This follows the Gateway API pattern for cross-namespace route attachments.
6465
// If not specified, only Agents in the same namespace can reference this RemoteMCPServer.
6566
// See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-route-attachment
67+
//
68+
// A cross-namespace-permitting value (from: All or from: Selector) is
69+
// mutually exclusive with spec.tls.caCertSecretRef (enforced by a spec-level
70+
// XValidation rule): a pinned CA Secret is mounted onto the consuming agent's
71+
// pod by bare name and Kubernetes resolves it in the agent's namespace, not
72+
// this RemoteMCPServer's, so a CA-pinning RemoteMCPServer cannot be referenced
73+
// cross-namespace. from: Same (the default) is always allowed.
6674
// +optional
6775
AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"`
6876

go/api/v1alpha2/tlsconfig_cel_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,82 @@ func TestTLSConfigCELValidation(t *testing.T) {
217217
}
218218
},
219219
},
220+
// caCertSecretRef ⊥ cross-namespace-permitting allowedNamespaces: a pinned
221+
// CA Secret can't be mounted across namespaces. from=All / from=Selector
222+
// are rejected alongside a CA; from=Same (or omitted) is fine.
223+
{
224+
name: "RemoteMCPServer: allowedNamespaces from=All with caCertSecretRef rejected",
225+
build: func() ctrl_client.Object {
226+
return &RemoteMCPServer{
227+
ObjectMeta: metav1.ObjectMeta{Name: "rms-allowedns-all-ca", Namespace: ns},
228+
Spec: RemoteMCPServerSpec{
229+
Description: "test",
230+
URL: "https://upstream.example.com/mcp",
231+
AllowedNamespaces: &AllowedNamespaces{From: NamespacesFromAll},
232+
TLS: &TLSConfig{CACertSecretRef: "ca", CACertSecretKey: "ca.crt"},
233+
},
234+
}
235+
},
236+
wantReject: "spec.allowedNamespaces must not permit cross-namespace access",
237+
},
238+
{
239+
name: "RemoteMCPServer: allowedNamespaces from=Selector with caCertSecretRef rejected",
240+
build: func() ctrl_client.Object {
241+
return &RemoteMCPServer{
242+
ObjectMeta: metav1.ObjectMeta{Name: "rms-allowedns-selector-ca", Namespace: ns},
243+
Spec: RemoteMCPServerSpec{
244+
Description: "test",
245+
URL: "https://upstream.example.com/mcp",
246+
AllowedNamespaces: &AllowedNamespaces{
247+
From: NamespacesFromSelector,
248+
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "x"}},
249+
},
250+
TLS: &TLSConfig{CACertSecretRef: "ca", CACertSecretKey: "ca.crt"},
251+
},
252+
}
253+
},
254+
wantReject: "spec.allowedNamespaces must not permit cross-namespace access",
255+
},
256+
{
257+
name: "RemoteMCPServer: allowedNamespaces from=Same with caCertSecretRef accepted",
258+
build: func() ctrl_client.Object {
259+
return &RemoteMCPServer{
260+
ObjectMeta: metav1.ObjectMeta{Name: "rms-allowedns-same-ca", Namespace: ns},
261+
Spec: RemoteMCPServerSpec{
262+
Description: "test",
263+
URL: "https://upstream.example.com/mcp",
264+
AllowedNamespaces: &AllowedNamespaces{From: NamespacesFromSame},
265+
TLS: &TLSConfig{CACertSecretRef: "ca", CACertSecretKey: "ca.crt"},
266+
},
267+
}
268+
},
269+
},
270+
{
271+
name: "RemoteMCPServer: allowedNamespaces from=All without CA accepted",
272+
build: func() ctrl_client.Object {
273+
return &RemoteMCPServer{
274+
ObjectMeta: metav1.ObjectMeta{Name: "rms-allowedns-no-ca", Namespace: ns},
275+
Spec: RemoteMCPServerSpec{
276+
Description: "test",
277+
URL: "https://upstream.example.com/mcp",
278+
AllowedNamespaces: &AllowedNamespaces{From: NamespacesFromAll},
279+
},
280+
}
281+
},
282+
},
283+
{
284+
name: "RemoteMCPServer: caCertSecretRef without allowedNamespaces accepted",
285+
build: func() ctrl_client.Object {
286+
return &RemoteMCPServer{
287+
ObjectMeta: metav1.ObjectMeta{Name: "rms-ca-no-allowedns", Namespace: ns},
288+
Spec: RemoteMCPServerSpec{
289+
Description: "test",
290+
URL: "https://upstream.example.com/mcp",
291+
TLS: &TLSConfig{CACertSecretRef: "ca", CACertSecretKey: "ca.crt"},
292+
},
293+
}
294+
},
295+
},
220296
}
221297

222298
for _, c := range cases {

helm/kagent-crds/templates/kagent.dev_remotemcpservers.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ spec:
5959
This follows the Gateway API pattern for cross-namespace route attachments.
6060
If not specified, only Agents in the same namespace can reference this RemoteMCPServer.
6161
See: https://gateway-api.sigs.k8s.io/guides/multiple-ns/#cross-namespace-route-attachment
62+
63+
A cross-namespace-permitting value (from: All or from: Selector) is
64+
mutually exclusive with spec.tls.caCertSecretRef (enforced by a spec-level
65+
XValidation rule): a pinned CA Secret is mounted onto the consuming agent's
66+
pod by bare name and Kubernetes resolves it in the agent's namespace, not
67+
this RemoteMCPServer's, so a CA-pinning RemoteMCPServer cannot be referenced
68+
cross-namespace. from: Same (the default) is always allowed.
6269
properties:
6370
from:
6471
default: Same
@@ -255,6 +262,15 @@ spec:
255262
TLS opinion contradicts a plaintext URL. Either drop spec.tls, or
256263
use https:// / a scheme-less URL.'
257264
rule: '!self.url.startsWith(''http://'') || !has(self.tls)'
265+
- message: 'spec.allowedNamespaces must not permit cross-namespace access
266+
(from: All or from: Selector) when spec.tls.caCertSecretRef is set:
267+
a pinned CA Secret is mounted onto the consuming agent''s pod and
268+
Kubernetes resolves it in the agent''s namespace, not this RemoteMCPServer''s.
269+
Use from: Same (the default), or remove the CA Secret reference.'
270+
rule: '!(has(self.allowedNamespaces) && has(self.allowedNamespaces.from)
271+
&& (self.allowedNamespaces.from == ''All'' || self.allowedNamespaces.from
272+
== ''Selector'') && has(self.tls) && has(self.tls.caCertSecretRef)
273+
&& size(self.tls.caCertSecretRef) > 0)'
258274
status:
259275
description: RemoteMCPServerStatus defines the observed state of RemoteMCPServer.
260276
properties:

0 commit comments

Comments
 (0)