diff --git a/pkg/kubernetes/accesscontrol.go b/pkg/kubernetes/accesscontrol.go new file mode 100644 index 00000000..fa9a002b --- /dev/null +++ b/pkg/kubernetes/accesscontrol.go @@ -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()) +} diff --git a/pkg/kubernetes/accesscontrol_clientset.go b/pkg/kubernetes/accesscontrol_clientset.go new file mode 100644 index 00000000..f5c06ffc --- /dev/null +++ b/pkg/kubernetes/accesscontrol_clientset.go @@ -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 +} diff --git a/pkg/kubernetes/accesscontrol_restmapper.go b/pkg/kubernetes/accesscontrol_restmapper.go new file mode 100644 index 00000000..a821717c --- /dev/null +++ b/pkg/kubernetes/accesscontrol_restmapper.go @@ -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} +} diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index e44f92fc..7ed808c4 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -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 - - StaticConfig *config.StaticConfig + Kubeconfig string + cfg *rest.Config + clientCmdConfig clientcmd.ClientConfig + discoveryClient discovery.CachedDiscoveryInterface + accessControlClientSet *AccessControlClientset + accessControlRESTMapper *AccessControlRESTMapper + dynamicClient *dynamic.DynamicClient + + 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} diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index f5a96c91..1c7e704c 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -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) - }) -} diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index efd08f75..ea38a9bb 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -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, diff --git a/pkg/mcp/events_test.go b/pkg/mcp/events_test.go index 1d3c2f0b..cc503169 100644 --- a/pkg/mcp/events_test.go +++ b/pkg/mcp/events_test.go @@ -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) } }) }) diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index 6eda0264..4f0c4fe3 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -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) { diff --git a/pkg/mcp/namespaces_test.go b/pkg/mcp/namespaces_test.go index 05280dcc..b8b884d3 100644 --- a/pkg/mcp/namespaces_test.go +++ b/pkg/mcp/namespaces_test.go @@ -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) } }) }) diff --git a/pkg/mcp/pods_exec_test.go b/pkg/mcp/pods_exec_test.go index b869ddc6..3303f4f3 100644 --- a/pkg/mcp/pods_exec_test.go +++ b/pkg/mcp/pods_exec_test.go @@ -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) + } + }) + }) } diff --git a/pkg/mcp/pods_test.go b/pkg/mcp/pods_test.go index d3a22737..783f7a51 100644 --- a/pkg/mcp/pods_test.go +++ b/pkg/mcp/pods_test.go @@ -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) } }) }) diff --git a/pkg/mcp/pods_top_test.go b/pkg/mcp/pods_top_test.go index 8ef6f6f8..f45add0d 100644 --- a/pkg/mcp/pods_top_test.go +++ b/pkg/mcp/pods_top_test.go @@ -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) + } + }) + }) } diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go index 7d176760..b62dc816 100644 --- a/pkg/mcp/resources_test.go +++ b/pkg/mcp/resources_test.go @@ -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"})