Skip to content

Commit 3522e4f

Browse files
committed
feat(kubernetes): fallback to configured namespace when listing from all namespaces
Fixes #4 If user is not authorized to list from all namespaces try to list from the configured namespace only.
1 parent 90c2802 commit 3522e4f

File tree

4 files changed

+139
-14
lines changed

4 files changed

+139
-14
lines changed

pkg/kubernetes/kubernetes.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,16 @@ func resolveClientConfig() (*rest.Config, error) {
9595
return resolveConfig().ClientConfig()
9696
}
9797

98+
func configuredNamespace() string {
99+
if ns, _, nsErr := resolveConfig().Namespace(); nsErr == nil {
100+
return ns
101+
}
102+
return ""
103+
}
104+
98105
func namespaceOrDefault(namespace string) string {
99106
if namespace == "" {
100-
if ns, _, nsErr := resolveConfig().Namespace(); nsErr == nil {
101-
namespace = ns
102-
}
107+
return configuredNamespace()
103108
}
104109
return namespace
105110
}

pkg/kubernetes/resources.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kubernetes
33
import (
44
"context"
55
"github.com/manusa/kubernetes-mcp-server/pkg/version"
6+
authv1 "k8s.io/api/authorization/v1"
67
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
78
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
89
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -23,6 +24,11 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
2324
if err != nil {
2425
return "", err
2526
}
27+
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
28+
isNamespaced, _ := k.isNamespaced(gvk)
29+
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
30+
namespace = configuredNamespace()
31+
}
2632
rl, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
2733
if err != nil {
2834
return "", err
@@ -125,3 +131,20 @@ func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
125131
}
126132
return true
127133
}
134+
135+
func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool {
136+
response, err := k.clientSet.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authv1.SelfSubjectAccessReview{
137+
Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{
138+
Namespace: namespace,
139+
Verb: verb,
140+
Group: gvr.Group,
141+
Version: gvr.Version,
142+
Resource: gvr.Resource,
143+
}},
144+
}, metav1.CreateOptions{})
145+
if err != nil {
146+
// TODO: maybe return the error too
147+
return false
148+
}
149+
return response.Status.Allowed
150+
}

pkg/mcp/common_test.go

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/mark3labs/mcp-go/server"
1010
"github.com/spf13/afero"
1111
corev1 "k8s.io/api/core/v1"
12+
rbacv1 "k8s.io/api/rbac/v1"
1213
apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1314
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
1415
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -34,6 +35,7 @@ import (
3435
// envTest has an expensive setup, so we only want to do it once per entire test run.
3536
var envTest *envtest.Environment
3637
var envTestRestConfig *rest.Config
38+
var envTestUser = envtest.User{Name: "test-user", Groups: []string{"test:users"}}
3739

3840
func TestMain(m *testing.M) {
3941
// Set up
@@ -62,9 +64,17 @@ func TestMain(m *testing.M) {
6264
envTest = &envtest.Environment{
6365
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
6466
}
65-
envTestRestConfig, _ = envTest.Start()
66-
kc, _ := kubernetes.NewForConfig(envTestRestConfig)
67-
createTestData(context.Background(), kc)
67+
adminSystemMasterBaseConfig, _ := envTest.Start()
68+
au, err := envTest.AddUser(envTestUser, adminSystemMasterBaseConfig)
69+
if err != nil {
70+
panic(err)
71+
}
72+
envTestRestConfig = au.Config()
73+
74+
//Create test data as administrator
75+
ctx := context.Background()
76+
restoreAuth(ctx)
77+
createTestData(ctx)
6878

6979
// Test!
7080
code := m.Run()
@@ -232,25 +242,46 @@ func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.Ca
232242
return c.mcpClient.CallTool(c.ctx, callToolRequest)
233243
}
234244

235-
func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
236-
_, _ = kc.CoreV1().Namespaces().
245+
func restoreAuth(ctx context.Context) {
246+
kubernetesAdmin := kubernetes.NewForConfigOrDie(envTest.Config)
247+
// Authorization
248+
_, _ = kubernetesAdmin.RbacV1().ClusterRoles().Update(ctx, &rbacv1.ClusterRole{
249+
ObjectMeta: metav1.ObjectMeta{Name: "allow-all"},
250+
Rules: []rbacv1.PolicyRule{{
251+
Verbs: []string{"*"},
252+
APIGroups: []string{"*"},
253+
Resources: []string{"*"},
254+
}},
255+
}, metav1.UpdateOptions{})
256+
_, _ = kubernetesAdmin.RbacV1().ClusterRoleBindings().Update(ctx, &rbacv1.ClusterRoleBinding{
257+
ObjectMeta: metav1.ObjectMeta{Name: "allow-all"},
258+
Subjects: []rbacv1.Subject{{Kind: "Group", Name: envTestUser.Groups[0]}},
259+
RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "allow-all"},
260+
}, metav1.UpdateOptions{})
261+
}
262+
263+
func createTestData(ctx context.Context) {
264+
kubernetesAdmin := kubernetes.NewForConfigOrDie(envTestRestConfig)
265+
// Namespaces
266+
_, _ = kubernetesAdmin.CoreV1().Namespaces().
237267
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{})
238-
_, _ = kc.CoreV1().Namespaces().
268+
_, _ = kubernetesAdmin.CoreV1().Namespaces().
239269
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{})
240-
_, _ = kc.CoreV1().Namespaces().
270+
_, _ = kubernetesAdmin.CoreV1().Namespaces().
241271
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-to-delete"}}, metav1.CreateOptions{})
242-
_, _ = kc.CoreV1().Pods("default").Create(ctx, &corev1.Pod{
272+
_, _ = kubernetesAdmin.CoreV1().Pods("default").Create(ctx, &corev1.Pod{
243273
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-default"},
244274
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
245275
}, metav1.CreateOptions{})
246-
_, _ = kc.CoreV1().Pods("ns-1").Create(ctx, &corev1.Pod{
276+
// Pods for listing
277+
_, _ = kubernetesAdmin.CoreV1().Pods("ns-1").Create(ctx, &corev1.Pod{
247278
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-1"},
248279
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
249280
}, metav1.CreateOptions{})
250-
_, _ = kc.CoreV1().Pods("ns-2").Create(ctx, &corev1.Pod{
281+
_, _ = kubernetesAdmin.CoreV1().Pods("ns-2").Create(ctx, &corev1.Pod{
251282
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-2"},
252283
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
253284
}, metav1.CreateOptions{})
254-
_, _ = kc.CoreV1().ConfigMaps("default").
285+
_, _ = kubernetesAdmin.CoreV1().ConfigMaps("default").
255286
Create(ctx, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "a-configmap-to-delete"}}, metav1.CreateOptions{})
256287
}

pkg/mcp/pods_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mcp
22

33
import (
44
corev1 "k8s.io/api/core/v1"
5+
rbacv1 "k8s.io/api/rbac/v1"
56
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
67
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
78
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -68,6 +69,65 @@ func TestPodsListInAllNamespaces(t *testing.T) {
6869
})
6970
}
7071

72+
func TestPodsListInAllNamespacesUnauthorized(t *testing.T) {
73+
testCase(t, func(c *mcpContext) {
74+
c.withEnvTest()
75+
defer restoreAuth(c.ctx)
76+
client := c.newKubernetesClient()
77+
// Authorize user only for default/configured namespace
78+
r, _ := client.RbacV1().Roles("default").Create(c.ctx, &rbacv1.Role{
79+
ObjectMeta: metav1.ObjectMeta{Name: "allow-pods-list"},
80+
Rules: []rbacv1.PolicyRule{{
81+
Verbs: []string{"get", "list"},
82+
APIGroups: []string{""},
83+
Resources: []string{"pods"},
84+
}},
85+
}, metav1.CreateOptions{})
86+
_, _ = client.RbacV1().RoleBindings("default").Create(c.ctx, &rbacv1.RoleBinding{
87+
ObjectMeta: metav1.ObjectMeta{Name: "allow-pods-list"},
88+
Subjects: []rbacv1.Subject{{Kind: "User", Name: envTestUser.Name}},
89+
RoleRef: rbacv1.RoleRef{Kind: "Role", Name: r.Name},
90+
}, metav1.CreateOptions{})
91+
// Deny cluster by removing cluster rule
92+
_ = client.RbacV1().ClusterRoles().Delete(c.ctx, "allow-all", metav1.DeleteOptions{})
93+
toolResult, err := c.callTool("pods_list", map[string]interface{}{})
94+
t.Run("pods_list returns pods list for default namespace only", func(t *testing.T) {
95+
if err != nil {
96+
t.Fatalf("call tool failed %v", err)
97+
return
98+
}
99+
if toolResult.IsError {
100+
t.Fatalf("call tool failed")
101+
return
102+
}
103+
})
104+
var decoded []unstructured.Unstructured
105+
err = yaml.Unmarshal([]byte(toolResult.Content[0].(map[string]interface{})["text"].(string)), &decoded)
106+
t.Run("pods_list has yaml content", func(t *testing.T) {
107+
if err != nil {
108+
t.Fatalf("invalid tool result content %v", err)
109+
return
110+
}
111+
})
112+
t.Run("pods_list returns 1 items", func(t *testing.T) {
113+
if len(decoded) != 1 {
114+
t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
115+
return
116+
}
117+
})
118+
t.Run("pods_list returns pod in default", func(t *testing.T) {
119+
if decoded[0].GetName() != "a-pod-in-default" {
120+
t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decoded[0].GetName())
121+
return
122+
}
123+
if decoded[0].GetNamespace() != "default" {
124+
t.Fatalf("invalid pod namespace, expected default, got %v", decoded[0].GetNamespace())
125+
return
126+
}
127+
})
128+
})
129+
}
130+
71131
func TestPodsListInNamespace(t *testing.T) {
72132
testCase(t, func(c *mcpContext) {
73133
c.withEnvTest()
@@ -184,6 +244,12 @@ func TestPodsGet(t *testing.T) {
184244
return
185245
}
186246
})
247+
t.Run("pods_get with name and nil namespace omits managed fields", func(t *testing.T) {
248+
if decodedNilNamespace.GetManagedFields() != nil {
249+
t.Fatalf("managed fields should be omitted, got %v", decodedNilNamespace.GetManagedFields())
250+
return
251+
}
252+
})
187253
podsGetInNamespace, err := c.callTool("pods_get", map[string]interface{}{
188254
"namespace": "ns-1",
189255
"name": "a-pod-in-ns-1",

0 commit comments

Comments
 (0)