Skip to content

Commit 9a977dc

Browse files
committed
refactor(kubevirt): Enforce access control in toolset API
Remove direct RESTConfig() exposure from Kubernetes type and add access-controlled methods (ResourcesListByGVR, RESTConfigForGVK) that validate resource access through AccessControlRESTMapper before allowing operations. Update all kubevirt tools to use controlled methods instead of creating their own uncontrolled dynamic clients. This prevents toolsets from bypassing the denied resources configuration and ensures all API access goes through the access control layer. Assisted-By: Claude <[email protected]> Signed-off-by: Lee Yarwood <[email protected]>
1 parent 860de99 commit 9a977dc

File tree

7 files changed

+126
-138
lines changed

7 files changed

+126
-138
lines changed

pkg/kubernetes/kubernetes.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package kubernetes
22

33
import (
44
"k8s.io/apimachinery/pkg/runtime"
5-
"k8s.io/client-go/rest"
65

76
"github.com/containers/kubernetes-mcp-server/pkg/helm"
87
"k8s.io/client-go/kubernetes/scheme"
@@ -31,14 +30,6 @@ func (k *Kubernetes) AccessControlClientset() *AccessControlClientset {
3130
return k.manager.accessControlClientSet
3231
}
3332

34-
// RESTConfig returns the Kubernetes REST configuration
35-
func (k *Kubernetes) RESTConfig() *rest.Config {
36-
if k.manager == nil {
37-
return nil
38-
}
39-
return k.manager.cfg
40-
}
41-
4233
var Scheme = scheme.Scheme
4334
var ParameterCodec = runtime.NewParameterCodec(Scheme)
4435

pkg/kubernetes/resources.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
1515
"k8s.io/apimachinery/pkg/runtime/schema"
1616
"k8s.io/apimachinery/pkg/util/yaml"
17+
"k8s.io/client-go/rest"
1718
)
1819

1920
const (
@@ -45,7 +46,56 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
4546
return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions)
4647
}
4748

49+
// ResourcesListByGVR lists resources using a GroupVersionResource directly.
50+
// Access control is enforced through the RESTMapper.
51+
// Use this when you need to query arbitrary resources not covered by ResourcesList.
52+
func (k *Kubernetes) ResourcesListByGVR(ctx context.Context, gvr schema.GroupVersionResource, namespace string, options metav1.ListOptions) (*unstructured.UnstructuredList, error) {
53+
if k.manager == nil {
54+
return nil, fmt.Errorf("kubernetes manager not initialized")
55+
}
56+
57+
// Validate access by converting GVR to GVK through the access control mapper
58+
gvk, err := k.manager.accessControlRESTMapper.KindFor(gvr)
59+
if err != nil {
60+
return nil, err // Access denied or resource not found
61+
}
62+
63+
// Also validate through resourceFor to ensure consistent access control
64+
validatedGVR, err := k.resourceFor(&gvk)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
return k.manager.dynamicClient.Resource(*validatedGVR).Namespace(namespace).List(ctx, options)
70+
}
71+
72+
// RESTConfigForGVK returns a copy of the REST config for creating custom clients.
73+
// Access control is enforced by validating the provided GVK.
74+
// This is useful for accessing subresources that aren't supported by the dynamic client.
75+
// The caller is responsible for configuring GroupVersion, APIPath, and NegotiatedSerializer
76+
// as needed for their specific use case.
77+
func (k *Kubernetes) RESTConfigForGVK(gvk *schema.GroupVersionKind) (*rest.Config, error) {
78+
if k.manager == nil {
79+
return nil, fmt.Errorf("kubernetes manager not initialized")
80+
}
81+
82+
// Validate access control for the resource kind
83+
if gvk != nil {
84+
_, err := k.resourceFor(gvk)
85+
if err != nil {
86+
return nil, err // Access denied or resource not found
87+
}
88+
}
89+
90+
// Return a copy of the REST config to avoid modifying the original
91+
return rest.CopyConfig(k.manager.cfg), nil
92+
}
93+
4894
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) {
95+
if k.manager == nil {
96+
return nil, fmt.Errorf("kubernetes manager not initialized")
97+
}
98+
4999
gvr, err := k.resourceFor(gvk)
50100
if err != nil {
51101
return nil, err
@@ -73,6 +123,10 @@ func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource strin
73123
}
74124

75125
func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) error {
126+
if k.manager == nil {
127+
return fmt.Errorf("kubernetes manager not initialized")
128+
}
129+
76130
gvr, err := k.resourceFor(gvk)
77131
if err != nil {
78132
return err

pkg/toolsets/kubevirt/vm/create/tool.go

Lines changed: 10 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1313
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1414
"k8s.io/apimachinery/pkg/runtime/schema"
15-
"k8s.io/client-go/dynamic"
1615
"k8s.io/utils/ptr"
1716
)
1817

@@ -464,18 +463,6 @@ func getDefaultContainerDisks() []DataSourceInfo {
464463

465464
// searchDataSources searches for DataSource resources in the cluster
466465
func searchDataSources(params api.ToolHandlerParams, query string) ([]DataSourceInfo, error) {
467-
// Get dynamic client for querying DataSources
468-
restConfig := params.RESTConfig()
469-
if restConfig == nil {
470-
return nil, fmt.Errorf("REST config is nil")
471-
}
472-
473-
dynamicClient, err := dynamic.NewForConfig(restConfig)
474-
if err != nil {
475-
// Return just the built-in containerdisk images
476-
return getDefaultContainerDisks(), nil
477-
}
478-
479466
// DataSource GVR for CDI
480467
dataSourceGVR := schema.GroupVersionResource{
481468
Group: "cdi.kubevirt.io",
@@ -484,7 +471,7 @@ func searchDataSources(params api.ToolHandlerParams, query string) ([]DataSource
484471
}
485472

486473
// Collect DataSources from well-known namespaces and all namespaces
487-
results := collectDataSources(params, dynamicClient, dataSourceGVR)
474+
results := collectDataSources(params, dataSourceGVR)
488475

489476
// Add common containerdisk images
490477
results = append(results, getDefaultContainerDisks()...)
@@ -504,7 +491,7 @@ func searchDataSources(params api.ToolHandlerParams, query string) ([]DataSource
504491
}
505492

506493
// collectDataSources collects DataSources from well-known namespaces and all namespaces
507-
func collectDataSources(params api.ToolHandlerParams, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource) []DataSourceInfo {
494+
func collectDataSources(params api.ToolHandlerParams, gvr schema.GroupVersionResource) []DataSourceInfo {
508495
var results []DataSourceInfo
509496

510497
// Try to list DataSources from well-known namespaces first
@@ -514,14 +501,14 @@ func collectDataSources(params api.ToolHandlerParams, dynamicClient dynamic.Inte
514501
}
515502

516503
for _, ns := range wellKnownNamespaces {
517-
dsInfos, err := listDataSourcesFromNamespace(params, dynamicClient, gvr, ns)
504+
dsInfos, err := listDataSourcesFromNamespace(params, gvr, ns)
518505
if err == nil {
519506
results = append(results, dsInfos...)
520507
}
521508
}
522509

523510
// List DataSources from all namespaces
524-
list, err := dynamicClient.Resource(gvr).List(params.Context, metav1.ListOptions{})
511+
list, err := params.ResourcesListByGVR(params.Context, gvr, "", metav1.ListOptions{})
525512
if err != nil {
526513
// If we found DataSources from well-known namespaces but couldn't list all, return what we have
527514
if len(results) > 0 {
@@ -587,9 +574,9 @@ func deduplicateAndMergeDataSources(existing []DataSourceInfo, items []unstructu
587574
}
588575

589576
// listDataSourcesFromNamespace lists DataSources from a specific namespace
590-
func listDataSourcesFromNamespace(params api.ToolHandlerParams, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, namespace string) ([]DataSourceInfo, error) {
577+
func listDataSourcesFromNamespace(params api.ToolHandlerParams, gvr schema.GroupVersionResource, namespace string) ([]DataSourceInfo, error) {
591578
var results []DataSourceInfo
592-
list, err := dynamicClient.Resource(gvr).Namespace(namespace).List(params.Context, metav1.ListOptions{})
579+
list, err := params.ResourcesListByGVR(params.Context, gvr, namespace, metav1.ListOptions{})
593580
if err != nil {
594581
return nil, err
595582
}
@@ -629,16 +616,6 @@ func searchPreferences(params api.ToolHandlerParams, namespace string) []Prefere
629616
return []PreferenceInfo{}
630617
}
631618

632-
restConfig := params.RESTConfig()
633-
if restConfig == nil {
634-
return []PreferenceInfo{}
635-
}
636-
637-
dynamicClient, err := dynamic.NewForConfig(restConfig)
638-
if err != nil {
639-
return []PreferenceInfo{}
640-
}
641-
642619
var results []PreferenceInfo
643620

644621
// Search for cluster-wide VirtualMachineClusterPreferences
@@ -648,7 +625,7 @@ func searchPreferences(params api.ToolHandlerParams, namespace string) []Prefere
648625
Resource: "virtualmachineclusterpreferences",
649626
}
650627

651-
clusterList, err := dynamicClient.Resource(clusterPreferenceGVR).List(params.Context, metav1.ListOptions{})
628+
clusterList, err := params.ResourcesListByGVR(params.Context, clusterPreferenceGVR, "", metav1.ListOptions{})
652629
if err == nil {
653630
for _, item := range clusterList.Items {
654631
results = append(results, PreferenceInfo{
@@ -664,7 +641,7 @@ func searchPreferences(params api.ToolHandlerParams, namespace string) []Prefere
664641
Resource: "virtualmachinepreferences",
665642
}
666643

667-
namespacedList, err := dynamicClient.Resource(namespacedPreferenceGVR).Namespace(namespace).List(params.Context, metav1.ListOptions{})
644+
namespacedList, err := params.ResourcesListByGVR(params.Context, namespacedPreferenceGVR, namespace, metav1.ListOptions{})
668645
if err == nil {
669646
for _, item := range namespacedList.Items {
670647
results = append(results, PreferenceInfo{
@@ -683,16 +660,6 @@ func searchInstancetypes(params api.ToolHandlerParams, namespace string) []Insta
683660
return []InstancetypeInfo{}
684661
}
685662

686-
restConfig := params.RESTConfig()
687-
if restConfig == nil {
688-
return []InstancetypeInfo{}
689-
}
690-
691-
dynamicClient, err := dynamic.NewForConfig(restConfig)
692-
if err != nil {
693-
return []InstancetypeInfo{}
694-
}
695-
696663
var results []InstancetypeInfo
697664

698665
// Search for cluster-wide VirtualMachineClusterInstancetypes
@@ -702,7 +669,7 @@ func searchInstancetypes(params api.ToolHandlerParams, namespace string) []Insta
702669
Resource: "virtualmachineclusterinstancetypes",
703670
}
704671

705-
clusterList, err := dynamicClient.Resource(clusterInstancetypeGVR).List(params.Context, metav1.ListOptions{})
672+
clusterList, err := params.ResourcesListByGVR(params.Context, clusterInstancetypeGVR, "", metav1.ListOptions{})
706673
if err == nil {
707674
for _, item := range clusterList.Items {
708675
results = append(results, InstancetypeInfo{
@@ -719,7 +686,7 @@ func searchInstancetypes(params api.ToolHandlerParams, namespace string) []Insta
719686
Resource: "virtualmachineinstancetypes",
720687
}
721688

722-
namespacedList, err := dynamicClient.Resource(namespacedInstancetypeGVR).Namespace(namespace).List(params.Context, metav1.ListOptions{})
689+
namespacedList, err := params.ResourcesListByGVR(params.Context, namespacedInstancetypeGVR, namespace, metav1.ListOptions{})
723690
if err == nil {
724691
for _, item := range namespacedList.Items {
725692
results = append(results, InstancetypeInfo{

pkg/toolsets/kubevirt/vm/delete/tool.go

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import (
55

66
"github.com/containers/kubernetes-mcp-server/pkg/api"
77
"github.com/google/jsonschema-go/jsonschema"
8-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
98
"k8s.io/apimachinery/pkg/runtime/schema"
10-
"k8s.io/client-go/dynamic"
119
"k8s.io/utils/ptr"
1210
)
1311

@@ -56,30 +54,15 @@ func deleteVM(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
5654
return api.NewToolCallResult("", err), nil
5755
}
5856

59-
// Get dynamic client
60-
restConfig := params.RESTConfig()
61-
if restConfig == nil {
62-
return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil
57+
// Define the VirtualMachine GVK
58+
gvk := schema.GroupVersionKind{
59+
Group: "kubevirt.io",
60+
Version: "v1",
61+
Kind: "VirtualMachine",
6362
}
6463

65-
dynamicClient, err := dynamic.NewForConfig(restConfig)
66-
if err != nil {
67-
return api.NewToolCallResult("", fmt.Errorf("failed to create dynamic client: %w", err)), nil
68-
}
69-
70-
// Define the VirtualMachine GVR
71-
gvr := schema.GroupVersionResource{
72-
Group: "kubevirt.io",
73-
Version: "v1",
74-
Resource: "virtualmachines",
75-
}
76-
77-
// Delete the VM
78-
err = dynamicClient.Resource(gvr).Namespace(namespace).Delete(
79-
params.Context,
80-
name,
81-
metav1.DeleteOptions{},
82-
)
64+
// Delete the VM using the access-controlled method
65+
err = params.ResourcesDelete(params.Context, &gvk, namespace, name)
8366
if err != nil {
8467
return api.NewToolCallResult("", fmt.Errorf("failed to delete VirtualMachine: %w", err)), nil
8568
}

pkg/toolsets/kubevirt/vm/pause/tool.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,17 @@ func pause(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
6868
return api.NewToolCallResult("", err), nil
6969
}
7070

71-
// Get REST config
72-
restConfig := params.RESTConfig()
73-
if restConfig == nil {
74-
return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil
71+
// Validate access to VirtualMachineInstance resource
72+
vmiGVK := schema.GroupVersionKind{
73+
Group: "kubevirt.io",
74+
Version: "v1",
75+
Kind: "VirtualMachineInstance",
76+
}
77+
78+
// Get REST config with access control validation
79+
restConfig, err := params.RESTConfigForGVK(&vmiGVK)
80+
if err != nil {
81+
return api.NewToolCallResult("", fmt.Errorf("failed to get REST config: %w", err)), nil
7582
}
7683

7784
// Create a REST client for the KubeVirt subresource API

pkg/toolsets/kubevirt/vm/start/tool.go

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@ import (
66
"github.com/containers/kubernetes-mcp-server/pkg/api"
77
"github.com/containers/kubernetes-mcp-server/pkg/output"
88
"github.com/google/jsonschema-go/jsonschema"
9-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
109
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1110
"k8s.io/apimachinery/pkg/runtime/schema"
12-
"k8s.io/client-go/dynamic"
1311
"k8s.io/utils/ptr"
1412
)
1513

@@ -58,29 +56,15 @@ func start(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
5856
return api.NewToolCallResult("", err), nil
5957
}
6058

61-
// Get dynamic client
62-
restConfig := params.RESTConfig()
63-
if restConfig == nil {
64-
return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil
59+
// Define the VirtualMachine GVK
60+
gvk := schema.GroupVersionKind{
61+
Group: "kubevirt.io",
62+
Version: "v1",
63+
Kind: "VirtualMachine",
6564
}
6665

67-
dynamicClient, err := dynamic.NewForConfig(restConfig)
68-
if err != nil {
69-
return api.NewToolCallResult("", fmt.Errorf("failed to create dynamic client: %w", err)), nil
70-
}
71-
72-
// Get the current VM
73-
gvr := schema.GroupVersionResource{
74-
Group: "kubevirt.io",
75-
Version: "v1",
76-
Resource: "virtualmachines",
77-
}
78-
79-
vm, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(
80-
params.Context,
81-
name,
82-
metav1.GetOptions{},
83-
)
66+
// Get the current VM using access-controlled method
67+
vm, err := params.ResourcesGet(params.Context, &gvk, namespace, name)
8468
if err != nil {
8569
return api.NewToolCallResult("", fmt.Errorf("failed to get VirtualMachine: %w", err)), nil
8670
}
@@ -90,15 +74,15 @@ func start(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
9074
return api.NewToolCallResult("", fmt.Errorf("failed to set runStrategy: %w", err)), nil
9175
}
9276

93-
// Update the VM
94-
updatedVM, err := dynamicClient.Resource(gvr).Namespace(namespace).Update(
95-
params.Context,
96-
vm,
97-
metav1.UpdateOptions{},
98-
)
77+
// Update the VM using access-controlled method
78+
updatedVMs, err := params.ResourcesCreateOrUpdate(params.Context, mustMarshalYAML(vm))
9979
if err != nil {
10080
return api.NewToolCallResult("", fmt.Errorf("failed to update VirtualMachine: %w", err)), nil
10181
}
82+
if len(updatedVMs) == 0 {
83+
return api.NewToolCallResult("", fmt.Errorf("no VirtualMachine returned after update")), nil
84+
}
85+
updatedVM := updatedVMs[0]
10286

10387
// Format the output
10488
marshalledYaml, err := output.MarshalYaml(updatedVM)
@@ -108,3 +92,12 @@ func start(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
10892

10993
return api.NewToolCallResult("# VirtualMachine started successfully\n"+marshalledYaml, nil), nil
11094
}
95+
96+
// mustMarshalYAML marshals an unstructured object to YAML string
97+
func mustMarshalYAML(obj *unstructured.Unstructured) string {
98+
yaml, err := output.MarshalYaml(obj)
99+
if err != nil {
100+
panic(fmt.Sprintf("failed to marshal object to YAML: %v", err))
101+
}
102+
return yaml
103+
}

0 commit comments

Comments
 (0)