-
Notifications
You must be signed in to change notification settings - Fork 178
feat(config): deny resources by using RESTMapper as an interceptor #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
3872cdb
78d92f2
8a0681a
9a6ebe8
8b39eea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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()) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| package kubernetes | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| authorizationv1api "k8s.io/api/authorization/v1" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/apimachinery/pkg/runtime/schema" | ||
| "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/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 { | ||
| 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) (*rest.Request, error) { | ||
|
||
| gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} | ||
| if !isAllowed(a.staticConfig, gvk) { | ||
| return nil, isNotAllowedError(gvk) | ||
| } | ||
| // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 | ||
| return a.delegate.CoreV1().RESTClient(). | ||
| Post(). | ||
| Resource("pods"). | ||
| Namespace(namespace). | ||
| Name(name). | ||
| SubResource("exec"), nil | ||
| } | ||
|
|
||
| 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{ | ||
| delegate: clientSet, | ||
| discoveryClient: clientSet.DiscoveryClient, | ||
| metricsV1beta1: metricsClient, | ||
| staticConfig: staticConfig, | ||
| }, nil | ||
| } | ||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In my opinion, we can pass
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
| } | ||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It just simply sets http 200 statically https://github.com/mark3labs/mcp-go/blob/1eddde7bd69b760f745a1b4064969cffcf97e935/server/streamable_http.go#L347 :(
There was a problem hiding this comment.
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