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
This commit is contained in:
Marc Nuri
2025-07-01 14:44:22 +02:00
committed by GitHub
parent 2a1a3e4fbd
commit af2a8cd19d
13 changed files with 424 additions and 156 deletions

View File

@@ -0,0 +1,40 @@
package kubernetes
import (
"fmt"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/manusa/kubernetes-mcp-server/pkg/config"
)
// isAllowed checks the resource is in denied list or not.
// If it is in denied list, this function returns false.
func isAllowed(
staticConfig *config.StaticConfig, // TODO: maybe just use the denied resource slice
gvk *schema.GroupVersionKind,
) bool {
if staticConfig == nil {
return true
}
for _, val := range staticConfig.DeniedResources {
// If kind is empty, that means Group/Version pair is denied entirely
if val.Kind == "" {
if gvk.Group == val.Group && gvk.Version == val.Version {
return false
}
}
if gvk.Group == val.Group &&
gvk.Version == val.Version &&
gvk.Kind == val.Kind {
return false
}
}
return true
}
func isNotAllowedError(gvk *schema.GroupVersionKind) error {
return fmt.Errorf("resource not allowed: %s", gvk.String())
}

View File

@@ -0,0 +1,130 @@
package kubernetes
import (
"context"
"fmt"
authorizationv1api "k8s.io/api/authorization/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/httpstream"
"k8s.io/client-go/discovery"
"k8s.io/client-go/kubernetes"
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
"k8s.io/metrics/pkg/apis/metrics"
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1"
"github.com/manusa/kubernetes-mcp-server/pkg/config"
)
// AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset
// Only a limited set of functions are implemented with a single point of access to the kubernetes API where
// apiVersion and kinds are checked for allowed access
type AccessControlClientset struct {
cfg *rest.Config
delegate kubernetes.Interface
discoveryClient discovery.DiscoveryInterface
metricsV1beta1 *metricsv1beta1.MetricsV1beta1Client
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
}
func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface {
return a.discoveryClient
}
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
return a.delegate.CoreV1().Pods(namespace), nil
}
func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
// Compute URL
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
execRequest := a.delegate.CoreV1().RESTClient().
Post().
Resource("pods").
Namespace(namespace).
Name(name).
SubResource("exec")
execRequest.VersionedParams(podExecOptions, ParameterCodec)
spdyExec, err := remotecommand.NewSPDYExecutor(a.cfg, "POST", execRequest.URL())
if err != nil {
return nil, err
}
webSocketExec, err := remotecommand.NewWebSocketExecutor(a.cfg, "GET", execRequest.URL().String())
if err != nil {
return nil, err
}
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
})
}
func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, name string, listOptions metav1.ListOptions) (*metrics.PodMetricsList, error) {
gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "PodMetrics"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
var err error
if name != "" {
m, err := a.metricsV1beta1.PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, name, err)
}
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
} else {
versionedMetrics, err = a.metricsV1beta1.PodMetricses(namespace).List(ctx, listOptions)
if err != nil {
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
}
}
convertedMetrics := &metrics.PodMetricsList{}
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
}
func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
return a.delegate.CoreV1().Services(namespace), nil
}
func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) {
gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil
}
func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) {
clientSet, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
metricsClient, err := metricsv1beta1.NewForConfig(cfg)
if err != nil {
return nil, err
}
return &AccessControlClientset{
cfg: cfg,
delegate: clientSet,
discoveryClient: clientSet.DiscoveryClient,
metricsV1beta1: metricsClient,
staticConfig: staticConfig,
}, nil
}

View File

@@ -0,0 +1,80 @@
package kubernetes
import (
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/restmapper"
"github.com/manusa/kubernetes-mcp-server/pkg/config"
)
type AccessControlRESTMapper struct {
delegate *restmapper.DeferredDiscoveryRESTMapper
staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice
}
var _ meta.RESTMapper = &AccessControlRESTMapper{}
func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
gvk, err := a.delegate.KindFor(resource)
if err != nil {
return schema.GroupVersionKind{}, err
}
if !isAllowed(a.staticConfig, &gvk) {
return schema.GroupVersionKind{}, isNotAllowedError(&gvk)
}
return gvk, nil
}
func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
gvks, err := a.delegate.KindsFor(resource)
if err != nil {
return nil, err
}
for i := range gvks {
if !isAllowed(a.staticConfig, &gvks[i]) {
return nil, isNotAllowedError(&gvks[i])
}
}
return gvks, nil
}
func (a AccessControlRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
return a.delegate.ResourceFor(input)
}
func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
return a.delegate.ResourcesFor(input)
}
func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
for _, version := range versions {
gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
}
return a.delegate.RESTMapping(gk, versions...)
}
func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
for _, version := range versions {
gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind}
if !isAllowed(a.staticConfig, gvk) {
return nil, isNotAllowedError(gvk)
}
}
return a.delegate.RESTMappings(gk, versions...)
}
func (a AccessControlRESTMapper) ResourceSingularizer(resource string) (singular string, err error) {
return a.delegate.ResourceSingularizer(resource)
}
func (a AccessControlRESTMapper) Reset() {
a.delegate.Reset()
}
func NewAccessControlRESTMapper(delegate *restmapper.DeferredDiscoveryRESTMapper, staticConfig *config.StaticConfig) *AccessControlRESTMapper {
return &AccessControlRESTMapper{delegate: delegate, staticConfig: staticConfig}
}

View File

@@ -2,17 +2,16 @@ package kubernetes
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
"strings"
"github.com/fsnotify/fsnotify"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
@@ -37,24 +36,27 @@ type Kubernetes struct {
type Manager struct {
// Kubeconfig path override
Kubeconfig string
cfg *rest.Config
clientCmdConfig clientcmd.ClientConfig
CloseWatchKubeConfig CloseWatchKubeConfig
scheme *runtime.Scheme
parameterCodec runtime.ParameterCodec
clientSet kubernetes.Interface
discoveryClient discovery.CachedDiscoveryInterface
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
dynamicClient *dynamic.DynamicClient
Kubeconfig string
cfg *rest.Config
clientCmdConfig clientcmd.ClientConfig
discoveryClient discovery.CachedDiscoveryInterface
accessControlClientSet *AccessControlClientset
accessControlRESTMapper *AccessControlRESTMapper
dynamicClient *dynamic.DynamicClient
StaticConfig *config.StaticConfig
staticConfig *config.StaticConfig
CloseWatchKubeConfig CloseWatchKubeConfig
}
var Scheme = scheme.Scheme
var ParameterCodec = runtime.NewParameterCodec(Scheme)
var _ helm.Kubernetes = &Manager{}
func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error) {
k8s := &Manager{
Kubeconfig: kubeconfig,
StaticConfig: config,
staticConfig: config,
}
if err := resolveKubernetesConfigurations(k8s); err != nil {
return nil, err
@@ -64,21 +66,19 @@ func NewManager(kubeconfig string, config *config.StaticConfig) (*Manager, error
// return &impersonateRoundTripper{original}
//})
var err error
k8s.clientSet, err = kubernetes.NewForConfig(k8s.cfg)
k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig)
if err != nil {
return nil, err
}
k8s.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(k8s.clientSet.CoreV1().RESTClient()))
k8s.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient)
k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient())
k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
k8s.staticConfig,
)
k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
if err != nil {
return nil, err
}
k8s.scheme = runtime.NewScheme()
if err = v1.AddToScheme(k8s.scheme); err != nil {
return nil, err
}
k8s.parameterCodec = runtime.NewParameterCodec(k8s.scheme)
return k8s, nil
}
@@ -129,7 +129,7 @@ func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error
}
func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
return m.deferredDiscoveryRESTMapper, nil
return m.accessControlRESTMapper, nil
}
func (m *Manager) Derived(ctx context.Context) *Kubernetes {
@@ -156,15 +156,16 @@ func (m *Manager) Derived(ctx context.Context) *Kubernetes {
Kubeconfig: m.Kubeconfig,
clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil),
cfg: derivedCfg,
scheme: m.scheme,
parameterCodec: m.parameterCodec,
}}
derived.manager.clientSet, err = kubernetes.NewForConfig(derived.manager.cfg)
derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
if err != nil {
return &Kubernetes{manager: m}
}
derived.manager.discoveryClient = memory.NewMemCacheClient(discovery.NewDiscoveryClient(derived.manager.clientSet.CoreV1().RESTClient()))
derived.manager.deferredDiscoveryRESTMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient)
derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
derived.manager.staticConfig,
)
derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
if err != nil {
return &Kubernetes{manager: m}

View File

@@ -5,21 +5,20 @@ import (
"context"
"errors"
"fmt"
"k8s.io/metrics/pkg/apis/metrics"
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
metricsclientset "k8s.io/metrics/pkg/client/clientset/versioned"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
labelutil "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/httpstream"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/rand"
"k8s.io/client-go/tools/remotecommand"
"k8s.io/metrics/pkg/apis/metrics"
metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
)
type PodsTopOptions struct {
@@ -49,7 +48,7 @@ func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unst
func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.manager.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
pod, err := k.ResourcesGet(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
if err != nil {
return "", err
}
@@ -62,11 +61,15 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
// Delete managed service
if isManaged {
if sl, _ := k.manager.clientSet.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{
services, err := k.manager.accessControlClientSet.Services(namespace)
if err != nil {
return "", err
}
if sl, _ := services.List(ctx, metav1.ListOptions{
LabelSelector: managedLabelSelector.String(),
}); sl != nil {
for _, svc := range sl.Items {
_ = k.manager.clientSet.CoreV1().Services(namespace).Delete(ctx, svc.Name, metav1.DeleteOptions{})
_ = services.Delete(ctx, svc.Name, metav1.DeleteOptions{})
}
}
}
@@ -86,12 +89,16 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st
}
return "Pod deleted successfully",
k.manager.clientSet.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{})
k.ResourcesDelete(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
}
func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string) (string, error) {
tailLines := int64(256)
req := k.manager.clientSet.CoreV1().Pods(k.NamespaceOrDefault(namespace)).GetLogs(name, &v1.PodLogOptions{
pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
if err != nil {
return "", err
}
req := pods.GetLogs(name, &v1.PodLogOptions{
TailLines: &tailLines,
Container: container,
})
@@ -197,30 +204,16 @@ func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metr
} else {
namespace = k.NamespaceOrDefault(namespace)
}
metricsClient, err := metricsclientset.NewForConfig(k.manager.cfg)
if err != nil {
return nil, fmt.Errorf("failed to create metrics client: %w", err)
}
versionedMetrics := &metricsv1beta1api.PodMetricsList{}
if options.Name != "" {
m, err := metricsClient.MetricsV1beta1().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err)
}
versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
} else {
versionedMetrics, err = metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, options.ListOptions)
if err != nil {
return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
}
}
convertedMetrics := &metrics.PodMetricsList{}
return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
return k.manager.accessControlClientSet.PodsMetricses(ctx, namespace, options.Name, options.ListOptions)
}
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
namespace = k.NamespaceOrDefault(namespace)
pod, err := k.manager.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
pods, err := k.manager.accessControlClientSet.Pods(namespace)
if err != nil {
return "", err
}
pod, err := pods.Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
}
@@ -237,7 +230,7 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st
Stdout: true,
Stderr: true,
}
executor, err := k.createExecutor(namespace, name, podExecOptions)
executor, err := k.manager.accessControlClientSet.PodsExec(namespace, name, podExecOptions)
if err != nil {
return "", err
}
@@ -256,26 +249,3 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st
}
return "", nil
}
func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
// Compute URL
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
req := k.manager.clientSet.CoreV1().RESTClient().
Post().
Resource("pods").
Namespace(namespace).
Name(name).
SubResource("exec")
req.VersionedParams(podExecOptions, k.manager.parameterCodec)
spdyExec, err := remotecommand.NewSPDYExecutor(k.manager.cfg, "POST", req.URL())
if err != nil {
return nil, err
}
webSocketExec, err := remotecommand.NewWebSocketExecutor(k.manager.cfg, "GET", req.URL().String())
if err != nil {
return nil, err
}
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
})
}

View File

@@ -34,10 +34,6 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
return nil, err
}
if !k.isAllowed(gvk) {
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
}
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
@@ -55,10 +51,6 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
return nil, err
}
if !k.isAllowed(gvk) {
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.NamespaceOrDefault(namespace)
@@ -86,10 +78,6 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
return err
}
if !k.isAllowed(gvk) {
return fmt.Errorf("resource not allowed: %s", gvk.String())
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.NamespaceOrDefault(namespace)
@@ -113,7 +101,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group
}
url = append(url, gvr.Resource)
var table metav1.Table
err := k.manager.clientSet.CoreV1().RESTClient().
err := k.manager.discoveryClient.RESTClient().
Get().
SetHeader("Accept", strings.Join([]string{
fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
@@ -121,7 +109,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group
"application/json",
}, ",")).
AbsPath(url...).
SpecificallyVersionedParams(&options.ListOptions, k.manager.parameterCodec, schema.GroupVersion{Version: "v1"}).
SpecificallyVersionedParams(&options.ListOptions, ParameterCodec, schema.GroupVersion{Version: "v1"}).
Do(ctx).Into(&table)
if err != nil {
return nil, err
@@ -152,10 +140,6 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
return nil, rErr
}
if !k.isAllowed(&gvk) {
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
}
namespace := obj.GetNamespace()
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
@@ -169,44 +153,20 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
}
// Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation)
if gvk.Kind == "CustomResourceDefinition" {
k.manager.deferredDiscoveryRESTMapper.Reset()
k.manager.accessControlRESTMapper.Reset()
}
}
return resources, nil
}
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
m, err := k.manager.deferredDiscoveryRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
m, err := k.manager.accessControlRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
if err != nil {
return nil, err
}
return &m.Resource, nil
}
// isAllowed checks the resource is in denied list or not.
// If it is in denied list, this function returns false.
func (k *Kubernetes) isAllowed(gvk *schema.GroupVersionKind) bool {
if k.manager.StaticConfig == nil {
return true
}
for _, val := range k.manager.StaticConfig.DeniedResources {
// If kind is empty, that means Group/Version pair is denied entirely
if val.Kind == "" {
if gvk.Group == val.Group && gvk.Version == val.Version {
return false
}
}
if gvk.Group == val.Group &&
gvk.Version == val.Version &&
gvk.Kind == val.Kind {
return false
}
}
return true
}
func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
if err != nil {
@@ -228,7 +188,11 @@ func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
}
func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool {
response, err := k.manager.clientSet.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authv1.SelfSubjectAccessReview{
accessReviews, err := k.manager.accessControlClientSet.SelfSubjectAccessReviews()
if err != nil {
return false
}
response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{
Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{
Namespace: namespace,
Verb: verb,

View File

@@ -108,7 +108,7 @@ func TestEventsListDenied(t *testing.T) {
t.Run("events_list describes denial", func(t *testing.T) {
expectedMessage := "failed to list events in all namespaces: resource not allowed: /v1, Kind=Event"
if eventList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, eventList.Content[0].(mcp.TextContent).Text)
}
})
})

View File

@@ -59,7 +59,6 @@ func TestHelmInstall(t *testing.T) {
}
func TestHelmInstallDenied(t *testing.T) {
t.Skip("To be implemented") // TODO: helm_install is not checking for denied resources
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
@@ -74,9 +73,10 @@ func TestHelmInstallDenied(t *testing.T) {
}
})
t.Run("helm_install describes denial", func(t *testing.T) {
expectedMessage := "failed to install helm chart: resource not allowed: /v1, Kind=Secret"
if helmInstall.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
toolOutput := helmInstall.Content[0].(mcp.TextContent).Text
expectedMessage := ": resource not allowed: /v1, Kind=Secret"
if !strings.HasPrefix(toolOutput, "failed to install helm chart") || !strings.HasSuffix(toolOutput, expectedMessage) {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, helmInstall.Content[0].(mcp.TextContent).Text)
}
})
})
@@ -225,7 +225,33 @@ func TestHelmUninstall(t *testing.T) {
}
func TestHelmUninstallDenied(t *testing.T) {
t.Skip("To be implemented") // TODO: helm_uninstall is not checking for denied resources
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Secret"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
kc := c.newKubernetesClient()
clearHelmReleases(c.ctx, kc)
_, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
},
Data: map[string][]byte{
"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
"\"name\":\"existent-release-to-uninstall\"," +
"\"info\":{\"status\":\"deployed\"}," +
"\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: secret-to-deny\\n namespace: default\\n\"" +
"}"))),
},
}, metav1.CreateOptions{})
helmUninstall, _ := c.callTool("helm_uninstall", map[string]interface{}{
"name": "existent-release-to-uninstall",
})
t.Run("helm_uninstall has error", func(t *testing.T) {
if !helmUninstall.IsError {
t.Fatalf("call tool should fail")
}
})
})
}
func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {

View File

@@ -62,7 +62,7 @@ func TestNamespacesListDenied(t *testing.T) {
t.Run("namespaces_list describes denial", func(t *testing.T) {
expectedMessage := "failed to list namespaces: resource not allowed: /v1, Kind=Namespace"
if namespacesList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, namespacesList.Content[0].(mcp.TextContent).Text)
}
})
})
@@ -167,7 +167,7 @@ func TestProjectsListInOpenShiftDenied(t *testing.T) {
t.Run("projects_list describes denial", func(t *testing.T) {
expectedMessage := "failed to list projects: resource not allowed: project.openshift.io/v1, Kind=Project"
if projectsList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
}
})
})

View File

@@ -2,6 +2,7 @@ package mcp
import (
"bytes"
"github.com/manusa/kubernetes-mcp-server/pkg/config"
"github.com/mark3labs/mcp-go/mcp"
"io"
v1 "k8s.io/api/core/v1"
@@ -101,5 +102,25 @@ func TestPodsExec(t *testing.T) {
}
func TestPodsExecDenied(t *testing.T) {
t.Skip("To be implemented") // TODO: exec is not checking for denied resources
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
podsRun, _ := c.callTool("pods_exec", map[string]interface{}{
"namespace": "default",
"name": "pod-to-exec",
"command": []interface{}{"ls", "-l"},
"container": "a-specific-container",
})
t.Run("pods_exec has error", func(t *testing.T) {
if !podsRun.IsError {
t.Fatalf("call tool should fail")
}
})
t.Run("pods_exec describes denial", func(t *testing.T) {
expectedMessage := "failed to exec in pod pod-to-exec in namespace default: resource not allowed: /v1, Kind=Pod"
if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
}
})
})
}

View File

@@ -190,7 +190,7 @@ func TestPodsListDenied(t *testing.T) {
t.Run("pods_list describes denial", func(t *testing.T) {
expectedMessage := "failed to list pods in all namespaces: resource not allowed: /v1, Kind=Pod"
if podsList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
}
})
podsListInNamespace, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{"namespace": "ns-1"})
@@ -202,7 +202,7 @@ func TestPodsListDenied(t *testing.T) {
t.Run("pods_list_in_namespace describes denial", func(t *testing.T) {
expectedMessage := "failed to list pods in namespace ns-1: resource not allowed: /v1, Kind=Pod"
if podsListInNamespace.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
}
})
})
@@ -425,7 +425,7 @@ func TestPodsGetDenied(t *testing.T) {
t.Run("pods_get describes denial", func(t *testing.T) {
expectedMessage := "failed to get pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
if podsGet.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
}
})
})
@@ -563,7 +563,6 @@ func TestPodsDelete(t *testing.T) {
}
func TestPodsDeleteDenied(t *testing.T) {
t.Skip("To be implemented") // TODO: delete is not checking for denied resources
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
@@ -576,7 +575,7 @@ func TestPodsDeleteDenied(t *testing.T) {
t.Run("pods_delete describes denial", func(t *testing.T) {
expectedMessage := "failed to delete pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
if podsDelete.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
}
})
})
@@ -723,7 +722,6 @@ func TestPodsLog(t *testing.T) {
}
func TestPodsLogDenied(t *testing.T) {
t.Skip("To be implemented") // TODO: log is not checking for denied resources
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Version: "v1", Kind: "Pod"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
c.withEnvTest()
@@ -734,9 +732,9 @@ func TestPodsLogDenied(t *testing.T) {
}
})
t.Run("pods_log describes denial", func(t *testing.T) {
expectedMessage := "failed to log pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
expectedMessage := "failed to get pod a-pod-in-default log in namespace : resource not allowed: /v1, Kind=Pod"
if podsLog.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
}
})
})
@@ -905,7 +903,7 @@ func TestPodsRunDenied(t *testing.T) {
t.Run("pods_run describes denial", func(t *testing.T) {
expectedMessage := "failed to run pod in namespace : resource not allowed: /v1, Kind=Pod"
if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
}
})
})

View File

@@ -1,10 +1,13 @@
package mcp
import (
"github.com/mark3labs/mcp-go/mcp"
"net/http"
"regexp"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/manusa/kubernetes-mcp-server/pkg/config"
)
func TestPodsTopMetricsUnavailable(t *testing.T) {
@@ -206,5 +209,40 @@ func TestPodsTopMetricsAvailable(t *testing.T) {
}
func TestPodsTopDenied(t *testing.T) {
t.Skip("To be implemented") // TODO: top is not checking for denied resources
deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "metrics.k8s.io", Version: "v1beta1"}}}
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
mockServer := NewMockServer()
defer mockServer.Close()
c.withKubeConfig(mockServer.config)
mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
if req.URL.Path == "/api" {
_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
return
}
// Request Performed by DiscoveryClient to Kube API (Get API Groups)
if req.URL.Path == "/apis" {
_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
return
}
// Request Performed by DiscoveryClient to Kube API (Get API Resources)
if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
return
}
}))
podsTop, _ := c.callTool("pods_top", map[string]interface{}{})
t.Run("pods_run has error", func(t *testing.T) {
if !podsTop.IsError {
t.Fatalf("call tool should fail")
}
})
t.Run("pods_run describes denial", func(t *testing.T) {
expectedMessage := "failed to get pods top: resource not allowed: metrics.k8s.io/v1beta1, Kind=PodMetrics"
if podsTop.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsTop.Content[0].(mcp.TextContent).Text)
}
})
})
}

View File

@@ -169,7 +169,7 @@ func TestResourcesListDenied(t *testing.T) {
t.Run("resources_list (denied by kind) describes denial", func(t *testing.T) {
expectedMessage := "failed to list resources: resource not allowed: /v1, Kind=Secret"
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
deniedByGroup, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"})
@@ -181,7 +181,7 @@ func TestResourcesListDenied(t *testing.T) {
t.Run("resources_list (denied by group) describes denial", func(t *testing.T) {
expectedMessage := "failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
allowedResource, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
@@ -381,7 +381,7 @@ func TestResourcesGetDenied(t *testing.T) {
t.Run("resources_get (denied by kind) describes denial", func(t *testing.T) {
expectedMessage := "failed to get resource: resource not allowed: /v1, Kind=Secret"
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
deniedByGroup, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
@@ -393,7 +393,7 @@ func TestResourcesGetDenied(t *testing.T) {
t.Run("resources_get (denied by group) describes denial", func(t *testing.T) {
expectedMessage := "failed to get resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
allowedResource, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"})
@@ -601,7 +601,7 @@ func TestResourcesCreateOrUpdateDenied(t *testing.T) {
t.Run("resources_create_or_update (denied by kind) describes denial", func(t *testing.T) {
expectedMessage := "failed to create or update resources: resource not allowed: /v1, Kind=Secret"
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
roleYaml := "apiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n name: a-denied-role\n namespace: default\n"
@@ -614,7 +614,7 @@ func TestResourcesCreateOrUpdateDenied(t *testing.T) {
t.Run("resources_create_or_update (denied by group) describes denial", func(t *testing.T) {
expectedMessage := "failed to create or update resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n"
@@ -766,7 +766,7 @@ func TestResourcesDeleteDenied(t *testing.T) {
t.Run("resources_delete (denied by kind) describes denial", func(t *testing.T) {
expectedMessage := "failed to delete resource: resource not allowed: /v1, Kind=Secret"
if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
deniedByGroup, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
@@ -778,7 +778,7 @@ func TestResourcesDeleteDenied(t *testing.T) {
t.Run("resources_delete (denied by group) describes denial", func(t *testing.T) {
expectedMessage := "failed to delete resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected desciptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
}
})
allowedResource, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "allowed-configmap-to-delete"})