Skip to content

Commit bcf0d30

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 bcf0d30

File tree

3 files changed

+133
-60
lines changed

3 files changed

+133
-60
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 {

0 commit comments

Comments
 (0)