Skip to content

Commit 66a75ad

Browse files
committed
feat(config): provide a limited clientset which check access
1 parent d6289c0 commit 66a75ad

File tree

9 files changed

+205
-59
lines changed

9 files changed

+205
-59
lines changed

pkg/kubernetes/accesscontrol.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package kubernetes
2+
3+
import (
4+
"fmt"
5+
6+
"k8s.io/apimachinery/pkg/runtime/schema"
7+
8+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
9+
)
10+
11+
// isAllowed checks the resource is in denied list or not.
12+
// If it is in denied list, this function returns false.
13+
func isAllowed(
14+
staticConfig *config.StaticConfig, // TODO: maybe just use the denied resource slice
15+
gvk *schema.GroupVersionKind,
16+
) bool {
17+
if staticConfig == nil {
18+
return true
19+
}
20+
21+
for _, val := range staticConfig.DeniedResources {
22+
// If kind is empty, that means Group/Version pair is denied entirely
23+
if val.Kind == "" {
24+
if gvk.Group == val.Group && gvk.Version == val.Version {
25+
return false
26+
}
27+
}
28+
if gvk.Group == val.Group &&
29+
gvk.Version == val.Version &&
30+
gvk.Kind == val.Kind {
31+
return false
32+
}
33+
}
34+
35+
return true
36+
}
37+
38+
func isNotAllowedError(gvk *schema.GroupVersionKind) error {
39+
return fmt.Errorf("resource not allowed: %s", gvk.String())
40+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package kubernetes
2+
3+
import (
4+
authorizationv1api "k8s.io/api/authorization/v1"
5+
"k8s.io/apimachinery/pkg/runtime/schema"
6+
"k8s.io/client-go/discovery"
7+
"k8s.io/client-go/kubernetes"
8+
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
9+
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
10+
"k8s.io/client-go/rest"
11+
12+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
13+
)
14+
15+
// AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset
16+
// Only a limited set of functions are implemented with a single point of access to the kubernetes API where
17+
// apiVersion and kinds are checked for allowed access
18+
type AccessControlClientset struct {
19+
delegate kubernetes.Interface
20+
discoveryClient discovery.DiscoveryInterface
21+
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
22+
}
23+
24+
func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface {
25+
return a.discoveryClient
26+
}
27+
28+
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
29+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
30+
if !isAllowed(a.staticConfig, gvk) {
31+
return nil, isNotAllowedError(gvk)
32+
}
33+
return a.delegate.CoreV1().Pods(namespace), nil
34+
}
35+
36+
func (a *AccessControlClientset) PodsExec(namespace, name string) (*rest.Request, error) {
37+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
38+
if !isAllowed(a.staticConfig, gvk) {
39+
return nil, isNotAllowedError(gvk)
40+
}
41+
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
42+
return a.delegate.CoreV1().RESTClient().
43+
Post().
44+
Resource("pods").
45+
Namespace(namespace).
46+
Name(name).
47+
SubResource("exec"), nil
48+
}
49+
50+
func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
51+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
52+
if !isAllowed(a.staticConfig, gvk) {
53+
return nil, isNotAllowedError(gvk)
54+
}
55+
return a.delegate.CoreV1().Services(namespace), nil
56+
}
57+
58+
func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) {
59+
gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"}
60+
if !isAllowed(a.staticConfig, gvk) {
61+
return nil, isNotAllowedError(gvk)
62+
}
63+
return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil
64+
}
65+
66+
func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) {
67+
clientSet, err := kubernetes.NewForConfig(cfg)
68+
if err != nil {
69+
return nil, err
70+
}
71+
return &AccessControlClientset{
72+
delegate: clientSet, discoveryClient: clientSet.DiscoveryClient, staticConfig: staticConfig,
73+
}, nil
74+
}

pkg/kubernetes/accesscontrol_restmapper.go

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package kubernetes
22

33
import (
4-
"fmt"
5-
64
"k8s.io/apimachinery/pkg/api/meta"
75
"k8s.io/apimachinery/pkg/runtime/schema"
86
"k8s.io/client-go/restmapper"
@@ -17,37 +15,13 @@ type AccessControlRESTMapper struct {
1715

1816
var _ meta.RESTMapper = &AccessControlRESTMapper{}
1917

20-
// isAllowed checks the resource is in denied list or not.
21-
// If it is in denied list, this function returns false.
22-
func (a AccessControlRESTMapper) isAllowed(gvk *schema.GroupVersionKind) bool {
23-
if a.staticConfig == nil {
24-
return true
25-
}
26-
27-
for _, val := range a.staticConfig.DeniedResources {
28-
// If kind is empty, that means Group/Version pair is denied entirely
29-
if val.Kind == "" {
30-
if gvk.Group == val.Group && gvk.Version == val.Version {
31-
return false
32-
}
33-
}
34-
if gvk.Group == val.Group &&
35-
gvk.Version == val.Version &&
36-
gvk.Kind == val.Kind {
37-
return false
38-
}
39-
}
40-
41-
return true
42-
}
43-
4418
func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
4519
gvk, err := a.delegate.KindFor(resource)
4620
if err != nil {
4721
return schema.GroupVersionKind{}, err
4822
}
49-
if !a.isAllowed(&gvk) {
50-
return schema.GroupVersionKind{}, fmt.Errorf("resource not allowed: %s", gvk.String())
23+
if !isAllowed(a.staticConfig, &gvk) {
24+
return schema.GroupVersionKind{}, isNotAllowedError(&gvk)
5125
}
5226
return gvk, nil
5327
}
@@ -58,8 +32,8 @@ func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource)
5832
return nil, err
5933
}
6034
for i := range gvks {
61-
if !a.isAllowed(&gvks[i]) {
62-
return nil, fmt.Errorf("resource not allowed: %s", gvks[i].String())
35+
if !isAllowed(a.staticConfig, &gvks[i]) {
36+
return nil, isNotAllowedError(&gvks[i])
6337
}
6438
}
6539
return gvks, nil
@@ -76,8 +50,8 @@ func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource)
7650
func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
7751
for _, version := range versions {
7852
gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
79-
if !a.isAllowed(gvk) {
80-
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
53+
if !isAllowed(a.staticConfig, gvk) {
54+
return nil, isNotAllowedError(gvk)
8155
}
8256
}
8357
return a.delegate.RESTMapping(gk, versions...)
@@ -86,8 +60,8 @@ func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...st
8660
func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
8761
for _, version := range versions {
8862
gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
89-
if !a.isAllowed(gvk) {
90-
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
63+
if !isAllowed(a.staticConfig, gvk) {
64+
return nil, isNotAllowedError(gvk)
9165
}
9266
}
9367
return a.delegate.RESTMappings(gk, versions...)

pkg/kubernetes/kubernetes.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"k8s.io/client-go/discovery"
1313
"k8s.io/client-go/discovery/cached/memory"
1414
"k8s.io/client-go/dynamic"
15-
"k8s.io/client-go/kubernetes"
1615
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
1716
"k8s.io/client-go/rest"
1817
"k8s.io/client-go/restmapper"
@@ -41,8 +40,8 @@ type Manager struct {
4140
clientCmdConfig clientcmd.ClientConfig
4241
scheme *runtime.Scheme
4342
parameterCodec runtime.ParameterCodec
44-
clientSet kubernetes.Interface
4543
discoveryClient discovery.CachedDiscoveryInterface
44+
accessControlClientSet *AccessControlClientset
4645
accessControlRESTMapper *AccessControlRESTMapper
4746
dynamicClient *dynamic.DynamicClient
4847

@@ -65,11 +64,11 @@ func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error
6564
// return &impersonateRoundTripper{original}
6665
//})
6766
var err error
68-
k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg)
67+
k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig)
6968
if err != nil {
7069
return nil, err
7170
}
72-
k8s.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(k8s.clientSet.CoreV1().RESTClient()))
71+
k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient())
7372
k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
7473
restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
7574
k8s.staticConfig,
@@ -163,11 +162,11 @@ func (m *Manager) Derived(ctx context.Context) *Kubernetes {
163162
scheme: m.scheme,
164163
parameterCodec: m.parameterCodec,
165164
}}
166-
derived.manager.clientSet, err = kubernetes.NewForConfig(derived.manager.cfg)
165+
derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
167166
if err != nil {
168167
return &Kubernetes{manager: m}
169168
}
170-
derived.manager.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(derived.manager.clientSet.CoreV1().RESTClient()))
169+
derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
171170
derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
172171
restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
173172
derived.manager.staticConfig,

pkg/kubernetes/pods.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unst
4949

5050
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
5151
namespace = k.NamespaceOrDefault(namespace)
52-
pod, err := k.manager.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
52+
pod, err := k.ResourcesGet(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
5353
if err != nil {
5454
return "", err
5555
}
@@ -62,11 +62,15 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
6262

6363
// Delete managed service
6464
if isManaged {
65-
if sl, _ := k.manager.clientSet.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{
65+
services, err := k.manager.accessControlClientSet.Services(namespace)
66+
if err != nil {
67+
return "", err
68+
}
69+
if sl, _ := services.List(ctx, metav1.ListOptions{
6670
LabelSelector: managedLabelSelector.String(),
6771
}); sl != nil {
6872
for _, svc := range sl.Items {
69-
_ = k.manager.clientSet.CoreV1().Services(namespace).Delete(ctx, svc.Name, metav1.DeleteOptions{})
73+
_ = services.Delete(ctx, svc.Name, metav1.DeleteOptions{})
7074
}
7175
}
7276
}
@@ -86,12 +90,16 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
8690

8791
}
8892
return "Pod deleted successfully",
89-
k.manager.clientSet.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{})
93+
k.ResourcesDelete(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
9094
}
9195

9296
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
9397
tailLines := int64(256)
94-
req := k.manager.clientSet.CoreV1().Pods(k.NamespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
98+
pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
99+
if err != nil {
100+
return "", err
101+
}
102+
req := pods.GetLogs(name, &v1.PodLogOptions{
95103
TailLines: &tailLines,
96104
Container: container,
97105
})
@@ -220,7 +228,11 @@ func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metr
220228

221229
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
222230
namespace = k.NamespaceOrDefault(namespace)
223-
pod, err := k.manager.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
231+
pods, err := k.manager.accessControlClientSet.Pods(namespace)
232+
if err != nil {
233+
return "", err
234+
}
235+
pod, err := pods.Get(ctx, name, metav1.GetOptions{})
224236
if err != nil {
225237
return "", err
226238
}
@@ -260,12 +272,10 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st
260272
func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
261273
// Compute URL
262274
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
263-
req := k.manager.clientSet.CoreV1().RESTClient().
264-
Post().
265-
Resource("pods").
266-
Namespace(namespace).
267-
Name(name).
268-
SubResource("exec")
275+
req, err := k.manager.accessControlClientSet.PodsExec(namespace, name)
276+
if err != nil {
277+
return nil, err
278+
}
269279
req.VersionedParams(podExecOptions, k.manager.parameterCodec)
270280
spdyExec, err := remotecommand.NewSPDYExecutor(k.manager.cfg, "POST", req.URL())
271281
if err != nil {

pkg/kubernetes/resources.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group
101101
}
102102
url = append(url, gvr.Resource)
103103
var table metav1.Table
104-
err := k.manager.clientSet.CoreV1().RESTClient().
104+
err := k.manager.discoveryClient.RESTClient().
105105
Get().
106106
SetHeader("Accept", strings.Join([]string{
107107
fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
@@ -188,7 +188,11 @@ func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
188188
}
189189

190190
func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool {
191-
response, err := k.manager.clientSet.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authv1.SelfSubjectAccessReview{
191+
accessReviews, err := k.manager.accessControlClientSet.SelfSubjectAccessReviews()
192+
if err != nil {
193+
return false
194+
}
195+
response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{
192196
Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{
193197
Namespace: namespace,
194198
Verb: verb,

pkg/mcp/helm_test.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,33 @@ func TestHelmUninstall(t *testing.T) {
225225
}
226226

227227
func TestHelmUninstallDenied(t *testing.T) {
228-
t.Skip("To be implemented") // TODO: helm_uninstall is not checking for denied resources
228+
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
229+
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
230+
c.withEnvTest()
231+
kc := c.newKubernetesClient()
232+
clearHelmReleases(c.ctx, kc)
233+
_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
234+
ObjectMeta: metav1.ObjectMeta{
235+
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
236+
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
237+
},
238+
Data: map[string][]byte{
239+
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
240+
"\"name\":\"existent-release-to-uninstall\"," +
241+
"\"info\":{\"status\":\"deployed\"}," +
242+
"\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: secret-to-deny\\n namespace: default\\n\"" +
243+
"}"))),
244+
},
245+
}, metav1.CreateOptions{})
246+
helmUninstall, _ := c.callTool("helm_uninstall", map[string]interface{}{
247+
"name": "existent-release-to-uninstall",
248+
})
249+
t.Run("helm_uninstall has error", func(t *testing.T) {
250+
if !helmUninstall.IsError {
251+
t.Fatalf("call tool should fail")
252+
}
253+
})
254+
})
229255
}
230256

231257
func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {

0 commit comments

Comments
 (0)