Skip to content

Commit af2a8cd

Browse files
authored
feat(config): deny resources by using RESTMapper as an interceptor (149)
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. --- feat(config): provide a limited clientset which check access --- review: addressed PR comments --- feat(config): provide a limited metrics clientset to check access --- review: addressed PR comments regarding pods_exec
1 parent 2a1a3e4 commit af2a8cd

13 files changed

+425
-157
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: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package kubernetes
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
authorizationv1api "k8s.io/api/authorization/v1"
8+
v1 "k8s.io/api/core/v1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/runtime/schema"
11+
"k8s.io/apimachinery/pkg/util/httpstream"
12+
"k8s.io/client-go/discovery"
13+
"k8s.io/client-go/kubernetes"
14+
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
15+
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
16+
"k8s.io/client-go/rest"
17+
"k8s.io/client-go/tools/remotecommand"
18+
"k8s.io/metrics/pkg/apis/metrics"
19+
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
20+
metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1"
21+
22+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
23+
)
24+
25+
// AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset
26+
// Only a limited set of functions are implemented with a single point of access to the kubernetes API where
27+
// apiVersion and kinds are checked for allowed access
28+
type AccessControlClientset struct {
29+
cfg *rest.Config
30+
delegate kubernetes.Interface
31+
discoveryClient discovery.DiscoveryInterface
32+
metricsV1beta1 *metricsv1beta1.MetricsV1beta1Client
33+
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
34+
}
35+
36+
func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface {
37+
return a.discoveryClient
38+
}
39+
40+
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
41+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
42+
if !isAllowed(a.staticConfig, gvk) {
43+
return nil, isNotAllowedError(gvk)
44+
}
45+
return a.delegate.CoreV1().Pods(namespace), nil
46+
}
47+
48+
func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
49+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
50+
if !isAllowed(a.staticConfig, gvk) {
51+
return nil, isNotAllowedError(gvk)
52+
}
53+
// Compute URL
54+
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
55+
execRequest := a.delegate.CoreV1().RESTClient().
56+
Post().
57+
Resource("pods").
58+
Namespace(namespace).
59+
Name(name).
60+
SubResource("exec")
61+
execRequest.VersionedParams(podExecOptions, ParameterCodec)
62+
spdyExec, err := remotecommand.NewSPDYExecutor(a.cfg, "POST", execRequest.URL())
63+
if err != nil {
64+
return nil, err
65+
}
66+
webSocketExec, err := remotecommand.NewWebSocketExecutor(a.cfg, "GET", execRequest.URL().String())
67+
if err != nil {
68+
return nil, err
69+
}
70+
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
71+
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
72+
})
73+
}
74+
75+
func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, name string, listOptions metav1.ListOptions) (*metrics.PodMetricsList, error) {
76+
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "PodMetrics"}
77+
if !isAllowed(a.staticConfig, gvk) {
78+
return nil, isNotAllowedError(gvk)
79+
}
80+
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
81+
var err error
82+
if name != "" {
83+
m, err := a.metricsV1beta1.PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{})
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, name, err)
86+
}
87+
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
88+
} else {
89+
versionedMetrics, err = a.metricsV1beta1.PodMetricses(namespace).List(ctx, listOptions)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
92+
}
93+
}
94+
convertedMetrics := &metrics.PodMetricsList{}
95+
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
96+
}
97+
98+
func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
99+
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
100+
if !isAllowed(a.staticConfig, gvk) {
101+
return nil, isNotAllowedError(gvk)
102+
}
103+
return a.delegate.CoreV1().Services(namespace), nil
104+
}
105+
106+
func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) {
107+
gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"}
108+
if !isAllowed(a.staticConfig, gvk) {
109+
return nil, isNotAllowedError(gvk)
110+
}
111+
return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil
112+
}
113+
114+
func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) {
115+
clientSet, err := kubernetes.NewForConfig(cfg)
116+
if err != nil {
117+
return nil, err
118+
}
119+
metricsClient, err := metricsv1beta1.NewForConfig(cfg)
120+
if err != nil {
121+
return nil, err
122+
}
123+
return &AccessControlClientset{
124+
cfg: cfg,
125+
delegate: clientSet,
126+
discoveryClient: clientSet.DiscoveryClient,
127+
metricsV1beta1: metricsClient,
128+
staticConfig: staticConfig,
129+
}, nil
130+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package kubernetes
2+
3+
import (
4+
"k8s.io/apimachinery/pkg/api/meta"
5+
"k8s.io/apimachinery/pkg/runtime/schema"
6+
"k8s.io/client-go/restmapper"
7+
8+
"github.com/manusa/kubernetes-mcp-server/pkg/config"
9+
)
10+
11+
type AccessControlRESTMapper struct {
12+
delegate *restmapper.DeferredDiscoveryRESTMapper
13+
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
14+
}
15+
16+
var _ meta.RESTMapper = &AccessControlRESTMapper{}
17+
18+
func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
19+
gvk, err := a.delegate.KindFor(resource)
20+
if err != nil {
21+
return schema.GroupVersionKind{}, err
22+
}
23+
if !isAllowed(a.staticConfig, &gvk) {
24+
return schema.GroupVersionKind{}, isNotAllowedError(&gvk)
25+
}
26+
return gvk, nil
27+
}
28+
29+
func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
30+
gvks, err := a.delegate.KindsFor(resource)
31+
if err != nil {
32+
return nil, err
33+
}
34+
for i := range gvks {
35+
if !isAllowed(a.staticConfig, &gvks[i]) {
36+
return nil, isNotAllowedError(&gvks[i])
37+
}
38+
}
39+
return gvks, nil
40+
}
41+
42+
func (a AccessControlRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
43+
return a.delegate.ResourceFor(input)
44+
}
45+
46+
func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
47+
return a.delegate.ResourcesFor(input)
48+
}
49+
50+
func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
51+
for _, version := range versions {
52+
gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
53+
if !isAllowed(a.staticConfig, gvk) {
54+
return nil, isNotAllowedError(gvk)
55+
}
56+
}
57+
return a.delegate.RESTMapping(gk, versions...)
58+
}
59+
60+
func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
61+
for _, version := range versions {
62+
gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
63+
if !isAllowed(a.staticConfig, gvk) {
64+
return nil, isNotAllowedError(gvk)
65+
}
66+
}
67+
return a.delegate.RESTMappings(gk, versions...)
68+
}
69+
70+
func (a AccessControlRESTMapper) ResourceSingularizer(resource string) (singular string, err error) {
71+
return a.delegate.ResourceSingularizer(resource)
72+
}
73+
74+
func (a AccessControlRESTMapper) Reset() {
75+
a.delegate.Reset()
76+
}
77+
78+
func NewAccessControlRESTMapper(delegate *restmapper.DeferredDiscoveryRESTMapper, staticConfig *config.StaticConfig) *AccessControlRESTMapper {
79+
return &AccessControlRESTMapper{delegate: delegate, staticConfig: staticConfig}
80+
}

pkg/kubernetes/kubernetes.go

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@ package kubernetes
22

33
import (
44
"context"
5+
"k8s.io/apimachinery/pkg/runtime"
56
"strings"
67

78
"github.com/fsnotify/fsnotify"
89

9-
v1 "k8s.io/api/core/v1"
1010
"k8s.io/apimachinery/pkg/api/meta"
11-
"k8s.io/apimachinery/pkg/runtime"
1211
"k8s.io/client-go/discovery"
1312
"k8s.io/client-go/discovery/cached/memory"
1413
"k8s.io/client-go/dynamic"
15-
"k8s.io/client-go/kubernetes"
14+
"k8s.io/client-go/kubernetes/scheme"
1615
"k8s.io/client-go/rest"
1716
"k8s.io/client-go/restmapper"
1817
"k8s.io/client-go/tools/clientcmd"
@@ -37,24 +36,27 @@ 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+
discoveryClient discovery.CachedDiscoveryInterface
43+
accessControlClientSet *AccessControlClientset
44+
accessControlRESTMapper *AccessControlRESTMapper
45+
dynamicClient *dynamic.DynamicClient
46+
47+
staticConfig *config.StaticConfig
48+
CloseWatchKubeConfig CloseWatchKubeConfig
5249
}
5350

51+
var Scheme = scheme.Scheme
52+
var ParameterCodec = runtime.NewParameterCodec(Scheme)
53+
54+
var _ helm.Kubernetes = &Manager{}
55+
5456
func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) {
5557
k8s := &Manager{
5658
Kubeconfig: kubeconfig,
57-
StaticConfig: config,
59+
staticConfig: config,
5860
}
5961
if err := resolveKubernetesConfigurations(k8s); err != nil {
6062
return nil, err
@@ -64,21 +66,19 @@ func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error
6466
// return &impersonateRoundTripper{original}
6567
//})
6668
var err error
67-
k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg)
69+
k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig)
6870
if err != nil {
6971
return nil, err
7072
}
71-
k8s.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(k8s.clientSet.CoreV1().RESTClient()))
72-
k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient)
73+
k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient())
74+
k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
75+
restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
76+
k8s.staticConfig,
77+
)
7378
k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
7479
if err != nil {
7580
return nil, err
7681
}
77-
k8s.scheme = runtime.NewScheme()
78-
if err = v1.AddToScheme(k8s.scheme); err != nil {
79-
return nil, err
80-
}
81-
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
8282
return k8s, nil
8383
}
8484

@@ -129,7 +129,7 @@ func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error
129129
}
130130

131131
func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
132-
return m.deferredDiscoveryRESTMapper, nil
132+
return m.accessControlRESTMapper, nil
133133
}
134134

135135
func (m *Manager) Derived(ctx context.Context) *Kubernetes {
@@ -156,15 +156,16 @@ func (m *Manager) Derived(ctx context.Context) *Kubernetes {
156156
Kubeconfig: m.Kubeconfig,
157157
clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil),
158158
cfg: derivedCfg,
159-
scheme: m.scheme,
160-
parameterCodec: m.parameterCodec,
161159
}}
162-
derived.manager.clientSet, err = kubernetes.NewForConfig(derived.manager.cfg)
160+
derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
163161
if err != nil {
164162
return &Kubernetes{manager: m}
165163
}
166-
derived.manager.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(derived.manager.clientSet.CoreV1().RESTClient()))
167-
derived.manager.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient)
164+
derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
165+
derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
166+
restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
167+
derived.manager.staticConfig,
168+
)
168169
derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
169170
if err != nil {
170171
return &Kubernetes{manager: m}

0 commit comments

Comments
 (0)