Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions pkg/kubernetes/accesscontrol.go
Original file line number Diff line number Diff line change
@@ -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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not a blocker for this PR):
We have to return 403: Forbidden HTTP Status. But I'm not really sure that mcp library we use support this. If it doesn't support returning http code we have, we'll have major issue in oauth side as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I don't think the library (or even the protocol) is prepared for fine-grained operation status codes yet.
As I understand it, the protocol negotiates authentication and authorization first, and then enables

image

}
130 changes: 130 additions & 0 deletions pkg/kubernetes/accesscontrol_clientset.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 80 additions & 0 deletions pkg/kubernetes/accesscontrol_restmapper.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, we can pass staticConfig in here instead of denied resource slice. Maybe in the future, we want to extend this based on allowed resources similar to denied resources.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, I'm leaning towards your TODO comment. We can pass only denyList instead of an entire staticConfig (as it includes different type of resources)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why I left a TODO comment, I think that we can leave it and reconsider it once we have more pieces of the puzzle.

}

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}
}
61 changes: 31 additions & 30 deletions pkg/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not a blocker for this PR)
It is unfortunate that we are tied to scheme.Scheme (as cluster is a dynamic environment, we'll miss some new resources that is not defined in our static scheme). But I understand the reason of this. I'd recommend moving this to its own scheme.go file.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's merge this in first and reiterate on that.
In this case, I just needed a quick way to access the Scheme and ParameterCodec. I recalled you mentioned about using a global Scheme at some other PR so went on this direction.

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
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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}
Expand Down
Loading
Loading