Skip to content

Commit d6289c0

Browse files
committed
feat(config): deny resources by using RESTMapper as an interceptor
This approach ensures that resources in the deny list are **always** processed regardless of the implementation. The RESTMapper takes care of verifying that the requested Group Version Kind complies with the deny list while checking for the REST endpoint.
1 parent 1968652 commit d6289c0

File tree

8 files changed

+154
-81
lines changed

8 files changed

+154
-81
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package kubernetes
2+
3+
import (
4+
"fmt"
5+
6+
"k8s.io/apimachinery/pkg/api/meta"
7+
"k8s.io/apimachinery/pkg/runtime/schema"
8+
"k8s.io/client-go/restmapper"
9+
10+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
11+
)
12+
13+
type AccessControlRESTMapper struct {
14+
delegate *restmapper.DeferredDiscoveryRESTMapper
15+
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
16+
}
17+
18+
var _ meta.RESTMapper = &AccessControlRESTMapper{}
19+
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+
44+
func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
45+
gvk, err := a.delegate.KindFor(resource)
46+
if err != nil {
47+
return schema.GroupVersionKind{}, err
48+
}
49+
if !a.isAllowed(&gvk) {
50+
return schema.GroupVersionKind{}, fmt.Errorf("resource not allowed: %s", gvk.String())
51+
}
52+
return gvk, nil
53+
}
54+
55+
func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
56+
gvks, err := a.delegate.KindsFor(resource)
57+
if err != nil {
58+
return nil, err
59+
}
60+
for i := range gvks {
61+
if !a.isAllowed(&gvks[i]) {
62+
return nil, fmt.Errorf("resource not allowed: %s", gvks[i].String())
63+
}
64+
}
65+
return gvks, nil
66+
}
67+
68+
func (a AccessControlRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
69+
return a.delegate.ResourceFor(input)
70+
}
71+
72+
func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
73+
return a.delegate.ResourcesFor(input)
74+
}
75+
76+
func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
77+
for _, version := range versions {
78+
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())
81+
}
82+
}
83+
return a.delegate.RESTMapping(gk, versions...)
84+
}
85+
86+
func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
87+
for _, version := range versions {
88+
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())
91+
}
92+
}
93+
return a.delegate.RESTMappings(gk, versions...)
94+
}
95+
96+
func (a AccessControlRESTMapper) ResourceSingularizer(resource string) (singular string, err error) {
97+
return a.delegate.ResourceSingularizer(resource)
98+
}
99+
100+
func (a AccessControlRESTMapper) Reset() {
101+
a.delegate.Reset()
102+
}
103+
104+
func NewAccessControlRESTMapper(delegate *restmapper.DeferredDiscoveryRESTMapper, staticConfig *config.StaticConfig) *AccessControlRESTMapper {
105+
return &AccessControlRESTMapper{delegate: delegate, staticConfig: staticConfig}
106+
}

pkg/kubernetes/kubernetes.go

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"k8s.io/client-go/discovery/cached/memory"
1414
"k8s.io/client-go/dynamic"
1515
"k8s.io/client-go/kubernetes"
16+
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
1617
"k8s.io/client-go/rest"
1718
"k8s.io/client-go/restmapper"
1819
"k8s.io/client-go/tools/clientcmd"
@@ -21,8 +22,6 @@ import (
2122

2223
"github.com/manusa/kubernetes-mcp-server/pkg/config"
2324
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
24-
25-
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
2625
)
2726

2827
const (
@@ -37,24 +36,26 @@ type Kubernetes struct {
3736

3837
type Manager struct {
3938
// Kubeconfig path override
40-
Kubeconfig string
41-
cfg *rest.Config
42-
clientCmdConfig clientcmd.ClientConfig
43-
CloseWatchKubeConfig CloseWatchKubeConfig
44-
scheme *runtime.Scheme
45-
parameterCodec runtime.ParameterCodec
46-
clientSet kubernetes.Interface
47-
discoveryClient discovery.CachedDiscoveryInterface
48-
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
49-
dynamicClient *dynamic.DynamicClient
50-
51-
StaticConfig *config.StaticConfig
39+
Kubeconfig string
40+
cfg *rest.Config
41+
clientCmdConfig clientcmd.ClientConfig
42+
scheme *runtime.Scheme
43+
parameterCodec runtime.ParameterCodec
44+
clientSet kubernetes.Interface
45+
discoveryClient discovery.CachedDiscoveryInterface
46+
accessControlRESTMapper *AccessControlRESTMapper
47+
dynamicClient *dynamic.DynamicClient
48+
49+
staticConfig *config.StaticConfig
50+
CloseWatchKubeConfig CloseWatchKubeConfig
5251
}
5352

53+
var _ helm.Kubernetes = &Manager{}
54+
5455
func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) {
5556
k8s := &Manager{
5657
Kubeconfig: kubeconfig,
57-
StaticConfig: config,
58+
staticConfig: config,
5859
}
5960
if err := resolveKubernetesConfigurations(k8s); err != nil {
6061
return nil, err
@@ -69,7 +70,10 @@ func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error
6970
return nil, err
7071
}
7172
k8s.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(k8s.clientSet.CoreV1().RESTClient()))
72-
k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient)
73+
k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
74+
restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
75+
k8s.staticConfig,
76+
)
7377
k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
7478
if err != nil {
7579
return nil, err
@@ -129,7 +133,7 @@ func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error
129133
}
130134

131135
func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
132-
return m.deferredDiscoveryRESTMapper, nil
136+
return m.accessControlRESTMapper, nil
133137
}
134138

135139
func (m *Manager) Derived(ctx context.Context) *Kubernetes {
@@ -164,7 +168,10 @@ func (m *Manager) Derived(ctx context.Context) *Kubernetes {
164168
return &Kubernetes{manager: m}
165169
}
166170
derived.manager.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(derived.manager.clientSet.CoreV1().RESTClient()))
167-
derived.manager.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient)
171+
derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
172+
restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
173+
derived.manager.staticConfig,
174+
)
168175
derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
169176
if err != nil {
170177
return &Kubernetes{manager: m}

pkg/kubernetes/resources.go

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
3434
return nil, err
3535
}
3636

37-
if !k.isAllowed(gvk) {
38-
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
39-
}
40-
4137
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
4238
isNamespaced, _ := k.isNamespaced(gvk)
4339
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
@@ -55,10 +51,6 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
5551
return nil, err
5652
}
5753

58-
if !k.isAllowed(gvk) {
59-
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
60-
}
61-
6254
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
6355
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
6456
namespace = k.NamespaceOrDefault(namespace)
@@ -86,10 +78,6 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
8678
return err
8779
}
8880

89-
if !k.isAllowed(gvk) {
90-
return fmt.Errorf("resource not allowed: %s", gvk.String())
91-
}
92-
9381
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
9482
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
9583
namespace = k.NamespaceOrDefault(namespace)
@@ -152,10 +140,6 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
152140
return nil, rErr
153141
}
154142

155-
if !k.isAllowed(&gvk) {
156-
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
157-
}
158-
159143
namespace := obj.GetNamespace()
160144
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
161145
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
@@ -169,44 +153,20 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
169153
}
170154
// Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation)
171155
if gvk.Kind == "CustomResourceDefinition" {
172-
k.manager.deferredDiscoveryRESTMapper.Reset()
156+
k.manager.accessControlRESTMapper.Reset()
173157
}
174158
}
175159
return resources, nil
176160
}
177161

178162
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
179-
m, err := k.manager.deferredDiscoveryRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
163+
m, err := k.manager.accessControlRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
180164
if err != nil {
181165
return nil, err
182166
}
183167
return &m.Resource, nil
184168
}
185169

186-
// isAllowed checks the resource is in denied list or not.
187-
// If it is in denied list, this function returns false.
188-
func (k *Kubernetes) isAllowed(gvk *schema.GroupVersionKind) bool {
189-
if k.manager.StaticConfig == nil {
190-
return true
191-
}
192-
193-
for _, val := range k.manager.StaticConfig.DeniedResources {
194-
// If kind is empty, that means Group/Version pair is denied entirely
195-
if val.Kind == "" {
196-
if gvk.Group == val.Group && gvk.Version == val.Version {
197-
return false
198-
}
199-
}
200-
if gvk.Group == val.Group &&
201-
gvk.Version == val.Version &&
202-
gvk.Kind == val.Kind {
203-
return false
204-
}
205-
}
206-
207-
return true
208-
}
209-
210170
func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
211171
apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
212172
if err != nil {

pkg/mcp/events_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ func TestEventsListDenied(t *testing.T) {
108108
t.Run("events_list describes denial", func(t *testing.T) {
109109
expectedMessage := "failed to list events in all namespaces: resource not allowed: /v1, Kind=Event"
110110
if eventList.Content[0].(mcp.TextContent).Text != expectedMessage {
111-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
111+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
112112
}
113113
})
114114
})

pkg/mcp/helm_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ func TestHelmInstall(t *testing.T) {
5959
}
6060

6161
func TestHelmInstallDenied(t *testing.T) {
62-
t.Skip("To be implemented") // TODO: helm_install is not checking for denied resources
6362
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
6463
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
6564
c.withEnvTest()
@@ -74,9 +73,10 @@ func TestHelmInstallDenied(t *testing.T) {
7473
}
7574
})
7675
t.Run("helm_install describes denial", func(t *testing.T) {
77-
expectedMessage := "failed to install helm chart: resource not allowed: /v1, Kind=Secret"
78-
if helmInstall.Content[0].(mcp.TextContent).Text != expectedMessage {
79-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
76+
toolOutput := helmInstall.Content[0].(mcp.TextContent).Text
77+
expectedMessage := ": resource not allowed: /v1, Kind=Secret"
78+
if !strings.HasPrefix(toolOutput, "failed to install helm chart") || !strings.HasSuffix(toolOutput, expectedMessage) {
79+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
8080
}
8181
})
8282
})

pkg/mcp/namespaces_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func TestNamespacesListDenied(t *testing.T) {
6262
t.Run("namespaces_list describes denial", func(t *testing.T) {
6363
expectedMessage := "failed to list namespaces: resource not allowed: /v1, Kind=Namespace"
6464
if namespacesList.Content[0].(mcp.TextContent).Text != expectedMessage {
65-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
65+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
6666
}
6767
})
6868
})
@@ -167,7 +167,7 @@ func TestProjectsListInOpenShiftDenied(t *testing.T) {
167167
t.Run("projects_list describes denial", func(t *testing.T) {
168168
expectedMessage := "failed to list projects: resource not allowed: project.openshift.io/v1, Kind=Project"
169169
if projectsList.Content[0].(mcp.TextContent).Text != expectedMessage {
170-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
170+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
171171
}
172172
})
173173
})

pkg/mcp/pods_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func TestPodsListDenied(t *testing.T) {
190190
t.Run("pods_list describes denial", func(t *testing.T) {
191191
expectedMessage := "failed to list pods in all namespaces: resource not allowed: /v1, Kind=Pod"
192192
if podsList.Content[0].(mcp.TextContent).Text != expectedMessage {
193-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
193+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
194194
}
195195
})
196196
podsListInNamespace, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{"namespace": "ns-1"})
@@ -202,7 +202,7 @@ func TestPodsListDenied(t *testing.T) {
202202
t.Run("pods_list_in_namespace describes denial", func(t *testing.T) {
203203
expectedMessage := "failed to list pods in namespace ns-1: resource not allowed: /v1, Kind=Pod"
204204
if podsListInNamespace.Content[0].(mcp.TextContent).Text != expectedMessage {
205-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
205+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
206206
}
207207
})
208208
})
@@ -425,7 +425,7 @@ func TestPodsGetDenied(t *testing.T) {
425425
t.Run("pods_get describes denial", func(t *testing.T) {
426426
expectedMessage := "failed to get pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
427427
if podsGet.Content[0].(mcp.TextContent).Text != expectedMessage {
428-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
428+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
429429
}
430430
})
431431
})
@@ -576,7 +576,7 @@ func TestPodsDeleteDenied(t *testing.T) {
576576
t.Run("pods_delete describes denial", func(t *testing.T) {
577577
expectedMessage := "failed to delete pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
578578
if podsDelete.Content[0].(mcp.TextContent).Text != expectedMessage {
579-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
579+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
580580
}
581581
})
582582
})
@@ -736,7 +736,7 @@ func TestPodsLogDenied(t *testing.T) {
736736
t.Run("pods_log describes denial", func(t *testing.T) {
737737
expectedMessage := "failed to log pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
738738
if podsLog.Content[0].(mcp.TextContent).Text != expectedMessage {
739-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
739+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
740740
}
741741
})
742742
})
@@ -905,7 +905,7 @@ func TestPodsRunDenied(t *testing.T) {
905905
t.Run("pods_run describes denial", func(t *testing.T) {
906906
expectedMessage := "failed to run pod in namespace : resource not allowed: /v1, Kind=Pod"
907907
if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
908-
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
908+
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
909909
}
910910
})
911911
})

0 commit comments

Comments
 (0)