diff --git a/README.md b/README.md index 1eb1f284f..092c81e4d 100644 --- a/README.md +++ b/README.md @@ -311,10 +311,10 @@ The following sets of tools are available (toolsets marked with ✓ in the Defau | Toolset | Description | Default | |----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | | config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | | kcp | Manage kcp workspaces and multi-tenancy features | | -| kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | | kubevirt | KubeVirt virtual machine management tools | | | helm | Tools for managing Helm charts and releases | ✓ | @@ -328,6 +328,72 @@ In case multi-cluster support is enabled (default) and you have access to multip
+kiali + +- **kiali_mesh_graph** - Returns the topology of a specific namespaces, health, status of the mesh and namespaces. Includes a mesh health summary overview with aggregated counts of healthy, degraded, and failing apps, workloads, and services. Use this for high-level overviews + - `graphType` (`string`) - Optional type of graph to return: 'versionedApp', 'app', 'service', 'workload', 'mesh' + - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) + - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph + - `rateInterval` (`string`) - Optional rate interval for fetching (e.g., '10m', '5m', '1h'). + +- **kiali_manage_istio_config_read** - Lists or gets Istio configuration objects (Gateways, VirtualServices, etc.) + - `action` (`string`) **(required)** - Action to perform: list or get + - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) - Name of the Istio object + - `namespace` (`string`) - Namespace containing the Istio object + - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **kiali_manage_istio_config** - Creates, patches, or deletes Istio configuration objects (Gateways, VirtualServices, etc.) + - `action` (`string`) **(required)** - Action to perform: create, patch, or delete + - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') + - `json_data` (`string`) - JSON data to apply or create the object + - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') + - `name` (`string`) - Name of the Istio object + - `namespace` (`string`) - Namespace containing the Istio object + - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1') + +- **kiali_get_resource_details** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh + - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces + - `resource_name` (`string`) - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all). + - `resource_type` (`string`) - Type of resource to get details for (service, workload) + +- **kiali_get_metrics** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh + - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional + - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' + - `duration` (`string`) - Time range to get metrics for (optional string - if provided, gets metrics (e.g., '1m', '5m', '1h'); if empty, get default 30m). + - `namespace` (`string`) **(required)** - Namespace to get resources from + - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional + - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '10m' + - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' + - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional + - `resource_name` (`string`) **(required)** - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all). + - `resource_type` (`string`) **(required)** - Type of resource to get details for (service, workload) + - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds + +- **kiali_workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. + - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) + - `namespace` (`string`) **(required)** - Namespace containing the workload + - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs + - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) + - `workload` (`string`) **(required)** - Name of the workload to get logs for + +- **kiali_get_traces** - Gets traces for a specific resource (app, service, workload) in a namespace, or gets detailed information for a specific trace by its ID. If traceId is provided, it returns detailed trace information and other parameters are not required. + - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional, only used when traceId is not provided) + - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional, defaults to 10 minutes after startMicros if not provided, only used when traceId is not provided) + - `limit` (`integer`) - Maximum number of traces to return (default: 100, only used when traceId is not provided) + - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional, only used when traceId is not provided) + - `namespace` (`string`) - Namespace to get resources from. Required if traceId is not provided. + - `resource_name` (`string`) - Name of the resource to get traces for. Required if traceId is not provided. + - `resource_type` (`string`) - Type of resource to get traces for (app, service, workload). Required if traceId is not provided. + - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional, defaults to 10 minutes before current time if not provided, only used when traceId is not provided) + - `tags` (`string`) - JSON string of tags to filter traces (optional, only used when traceId is not provided) + - `traceId` (`string`) - Unique identifier of the trace to retrieve detailed information for. If provided, this will return detailed trace information and other parameters (resource_type, namespace, resource_name) are not required. + +
+ +
+ config - **configuration_contexts_list** - List all available context names and associated server urls from the kubeconfig file @@ -453,72 +519,6 @@ In case multi-cluster support is enabled (default) and you have access to multip
-kiali - -- **kiali_mesh_graph** - Returns the topology of a specific namespaces, health, status of the mesh and namespaces. Includes a mesh health summary overview with aggregated counts of healthy, degraded, and failing apps, workloads, and services. Use this for high-level overviews - - `graphType` (`string`) - Optional type of graph to return: 'versionedApp', 'app', 'service', 'workload', 'mesh' - - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) - - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph - - `rateInterval` (`string`) - Optional rate interval for fetching (e.g., '10m', '5m', '1h'). - -- **kiali_manage_istio_config_read** - Lists or gets Istio configuration objects (Gateways, VirtualServices, etc.) - - `action` (`string`) **(required)** - Action to perform: list or get - - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - - `name` (`string`) - Name of the Istio object - - `namespace` (`string`) - Namespace containing the Istio object - - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1') - -- **kiali_manage_istio_config** - Creates, patches, or deletes Istio configuration objects (Gateways, VirtualServices, etc.) - - `action` (`string`) **(required)** - Action to perform: create, patch, or delete - - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - - `json_data` (`string`) - JSON data to apply or create the object - - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - - `name` (`string`) - Name of the Istio object - - `namespace` (`string`) - Namespace containing the Istio object - - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1') - -- **kiali_get_resource_details** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh - - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces - - `resource_name` (`string`) - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all). - - `resource_type` (`string`) - Type of resource to get details for (service, workload) - -- **kiali_get_metrics** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh - - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional - - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' - - `duration` (`string`) - Time range to get metrics for (optional string - if provided, gets metrics (e.g., '1m', '5m', '1h'); if empty, get default 30m). - - `namespace` (`string`) **(required)** - Namespace to get resources from - - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional - - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '10m' - - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' - - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional - - `resource_name` (`string`) **(required)** - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all). - - `resource_type` (`string`) **(required)** - Type of resource to get details for (service, workload) - - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds - -- **kiali_workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. - - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) - - `namespace` (`string`) **(required)** - Namespace containing the workload - - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs - - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) - - `workload` (`string`) **(required)** - Name of the workload to get logs for - -- **kiali_get_traces** - Gets traces for a specific resource (app, service, workload) in a namespace, or gets detailed information for a specific trace by its ID. If traceId is provided, it returns detailed trace information and other parameters are not required. - - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional, only used when traceId is not provided) - - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional, defaults to 10 minutes after startMicros if not provided, only used when traceId is not provided) - - `limit` (`integer`) - Maximum number of traces to return (default: 100, only used when traceId is not provided) - - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional, only used when traceId is not provided) - - `namespace` (`string`) - Namespace to get resources from. Required if traceId is not provided. - - `resource_name` (`string`) - Name of the resource to get traces for. Required if traceId is not provided. - - `resource_type` (`string`) - Type of resource to get traces for (app, service, workload). Required if traceId is not provided. - - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional, defaults to 10 minutes before current time if not provided, only used when traceId is not provided) - - `tags` (`string`) - JSON string of tags to filter traces (optional, only used when traceId is not provided) - - `traceId` (`string`) - Unique identifier of the trace to retrieve detailed information for. If provided, this will return detailed trace information and other parameters (resource_type, namespace, resource_name) are not required. - -
- -
- kubevirt - **vm_create** - Create a VirtualMachine in the cluster with the specified configuration, automatically resolving instance types, preferences, and container disk images. VM will be created in Halted state by default; use autostart parameter to start it immediately. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 000000000..003c289cc --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,120 @@ +# Pre-Execution Validation + +The kubernetes-mcp-server includes a validation layer that catches errors before they reach the Kubernetes API. This prevents AI hallucinations (like typos in resource names) and permission issues from causing confusing failures. + +## Why Validation? + +When an AI assistant makes a Kubernetes API call with errors, the raw Kubernetes error messages can be cryptic: + +``` +the server doesn't have a resource type "Deploymnt" +``` + +With validation enabled, you get clearer feedback: + +``` +Resource apps/v1/Deploymnt does not exist in the cluster +``` + +The validation layer catches these types of issues: + +1. **Resource Existence** - Catches typos like "Deploymnt" instead of "Deployment" (checked in access control) +2. **Schema Validation** - Catches invalid fields like "spec.replcias" instead of "spec.replicas" +3. **RBAC Validation** - Pre-checks permissions before attempting operations + +## Configuration + +Validation is **disabled by default**. Schema and RBAC validators run together when enabled. Resource existence is always checked as part of access control. + +```toml +# Enable all validation (default: false) +validation_enabled = true +``` + +### Configuration Reference + +| TOML Field | Default | Description | +|------------|---------|-------------| +| `validation_enabled` | `false` | Enable/disable all validators | + +**Note:** The schema validator caches the OpenAPI schema for 15 minutes internally. + +## How It Works + +### Validation Flow + +Validation happens at the HTTP RoundTripper level, intercepting all Kubernetes API calls: + +``` +MCP Tool Call → Kubernetes Client → HTTP RoundTripper → Kubernetes API + ↓ + Access Control + - Check deny list + - Check resource exists + ↓ + Schema Validator (if enabled) + "Are the fields valid?" + ↓ + RBAC Validator (if enabled) + "Does the user have permission?" + ↓ + Forward to K8s API +``` + +This HTTP-layer approach ensures **all** Kubernetes API calls are validated, including those from plugins (KubeVirt, Kiali, Helm, etc.) - not just the core tools. + +If any validator fails, the request is rejected with a clear error message before reaching the Kubernetes API. + +### 1. Resource Existence (Access Control) + +The access control layer validates that the requested resource type exists in the cluster. This check runs regardless of whether validation is enabled. + +**What it catches:** +- Typos in Kind names: "Deploymnt" → should be "Deployment" +- Wrong API versions: "apps/v2" → should be "apps/v1" +- Non-existent custom resources + +**Example error:** +``` +RESOURCE_NOT_FOUND: Resource deployments.apps does not exist in the cluster +``` + +### 2. Schema Validation + +Validates resource manifests against the cluster's OpenAPI schema for create/update operations. + +**What it catches:** +- Invalid field names: "spec.replcias" → should be "spec.replicas" +- Wrong field types: string where integer expected +- Missing required fields + +**Example error:** +``` +INVALID_FIELD: unknown field "spec.replcias" +``` + +**Note:** Schema validation uses kubectl's validation library and caches the OpenAPI schema for 15 minutes. + +### 3. RBAC Validation + +Pre-checks permissions using Kubernetes `SelfSubjectAccessReview` before attempting operations. + +**What it catches:** +- Missing permissions: can't create Deployments in namespace X +- Cluster-scoped vs namespace-scoped mismatches +- Read-only access attempting writes + +**Example error:** +``` +PERMISSION_DENIED: Cannot create deployments.apps in namespace "production" +``` + +**Note:** RBAC validation uses the same credentials as the actual operation - either the server's service account or the user's token (when OAuth is enabled). + +## Error Codes + +| Code | Description | +|------|-------------| +| `RESOURCE_NOT_FOUND` | The requested resource type doesn't exist in the cluster | +| `INVALID_FIELD` | A field in the manifest doesn't exist or has the wrong type | +| `PERMISSION_DENIED` | RBAC denies the requested operation | diff --git a/docs/configuration.md b/docs/configuration.md index 1c24e5608..7e9feb18f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -184,10 +184,10 @@ Toolsets group related tools together. Enable only the toolsets you need to redu | Toolset | Description | Default | |----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | | config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | | kcp | Manage kcp workspaces and multi-tenancy features | | -| kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | | kubevirt | KubeVirt virtual machine management tools | | | helm | Tools for managing Helm charts and releases | ✓ | diff --git a/pkg/api/config.go b/pkg/api/config.go index 85c095cd8..929a6f3e6 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -53,10 +53,16 @@ type StsConfigProvider interface { GetStsScopes() []string } +// ValidationEnabledProvider provides access to validation enabled setting. +type ValidationEnabledProvider interface { + IsValidationEnabled() bool +} + type BaseConfig interface { AuthProvider ClusterProvider DeniedResourcesProvider ExtendedConfigProvider StsConfigProvider + ValidationEnabledProvider } diff --git a/pkg/api/validation.go b/pkg/api/validation.go new file mode 100644 index 000000000..36f1dcedd --- /dev/null +++ b/pkg/api/validation.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// HTTPValidationRequest contains info extracted from an HTTP request for validation. +type HTTPValidationRequest struct { + GVR *schema.GroupVersionResource + GVK *schema.GroupVersionKind + HTTPMethod string // GET, POST, PUT, DELETE, PATCH + Verb string // get, list, create, update, delete, patch + Namespace string + ResourceName string + Body []byte // For create/update validation + Path string +} + +// HTTPValidator validates HTTP requests before they reach the K8s API server. +type HTTPValidator interface { + Validate(ctx context.Context, req *HTTPValidationRequest) error + Name() string +} + +// ValidationErrorCode categorizes validation failures. +type ValidationErrorCode string + +const ( + ErrorCodeResourceNotFound ValidationErrorCode = "RESOURCE_NOT_FOUND" + ErrorCodeInvalidField ValidationErrorCode = "INVALID_FIELD" + ErrorCodePermissionDenied ValidationErrorCode = "PERMISSION_DENIED" +) + +// ValidationError provides AI-friendly error information for validation failures. +type ValidationError struct { + Code ValidationErrorCode + Message string + Field string // optional, for field-level errors +} + +// Error implements the error interface. +func (e *ValidationError) Error() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Validation Error [%s]: %s", e.Code, e.Message)) + + if e.Field != "" { + sb.WriteString(fmt.Sprintf("\n Field: %s", e.Field)) + } + + return sb.String() +} + +// NewPermissionDeniedError creates an error for RBAC permission failures. +func NewPermissionDeniedError(verb, resource, namespace string) *ValidationError { + var msg string + if namespace != "" { + msg = fmt.Sprintf("Cannot %s %s in namespace %q", verb, resource, namespace) + } else { + msg = fmt.Sprintf("Cannot %s %s (cluster-scoped)", verb, resource) + } + + return &ValidationError{ + Code: ErrorCodePermissionDenied, + Message: msg, + } +} + +// FormatResourceName creates a human-readable resource identifier from GVR. +func FormatResourceName(gvr *schema.GroupVersionResource) string { + if gvr == nil { + return "unknown" + } + if gvr.Group == "" { + return gvr.Resource + } + return gvr.Resource + "." + gvr.Group +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 668cb6dd2..a4f8e53a1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -90,6 +90,11 @@ type StaticConfig struct { // These can also be configured via OTEL_* environment variables. Telemetry TelemetryConfig `toml:"telemetry,omitempty"` + // ValidationEnabled enables pre-execution validation of tool calls. + // When enabled, validates resources, schemas, and RBAC before execution. + // Defaults to false. + ValidationEnabled bool `toml:"validation_enabled,omitempty"` + // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]api.ExtendedConfig // Internal: parsed toolset configs (not exposed to TOML package) @@ -341,3 +346,7 @@ func (c *StaticConfig) GetStsAudience() string { func (c *StaticConfig) GetStsScopes() []string { return c.StsScopes } + +func (c *StaticConfig) IsValidationEnabled() bool { + return c.ValidationEnabled +} diff --git a/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go index 24bc513ee..a97db9adf 100644 --- a/pkg/kubernetes/accesscontrol_round_tripper.go +++ b/pkg/kubernetes/accesscontrol_round_tripper.go @@ -1,19 +1,57 @@ package kubernetes import ( + "bytes" "fmt" + "io" "net/http" "strings" "github.com/containers/kubernetes-mcp-server/pkg/api" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" + "k8s.io/klog/v2" ) +// AccessControlRoundTripper intercepts HTTP requests to enforce access control +// and optionally run validators before they reach the Kubernetes API. type AccessControlRoundTripper struct { delegate http.RoundTripper deniedResourcesProvider api.DeniedResourcesProvider restMapperProvider func() meta.RESTMapper + validationEnabled bool + validators []api.HTTPValidator +} + +// AccessControlRoundTripperConfig configures the AccessControlRoundTripper. +type AccessControlRoundTripperConfig struct { + Delegate http.RoundTripper + DeniedResourcesProvider api.DeniedResourcesProvider + RestMapperProvider func() meta.RESTMapper + DiscoveryProvider func() discovery.DiscoveryInterface + AuthClientProvider func() authv1client.AuthorizationV1Interface + ValidationEnabled bool +} + +// NewAccessControlRoundTripper creates a new AccessControlRoundTripper. +func NewAccessControlRoundTripper(cfg AccessControlRoundTripperConfig) *AccessControlRoundTripper { + rt := &AccessControlRoundTripper{ + delegate: cfg.Delegate, + deniedResourcesProvider: cfg.DeniedResourcesProvider, + restMapperProvider: cfg.RestMapperProvider, + validationEnabled: cfg.ValidationEnabled, + } + + if cfg.ValidationEnabled { + rt.validators = CreateValidators(ValidatorProviders{ + Discovery: cfg.DiscoveryProvider, + AuthClient: cfg.AuthClientProvider, + }) + } + + return rt } func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { @@ -33,12 +71,56 @@ func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Respons gvk, err := restMapper.KindFor(gvr) if err != nil { + if meta.IsNoMatchError(err) { + return nil, &api.ValidationError{ + Code: api.ErrorCodeResourceNotFound, + Message: fmt.Sprintf("Resource %s does not exist in the cluster", api.FormatResourceName(&gvr)), + } + } return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err) } if !rt.isAllowed(gvk) { return nil, fmt.Errorf("resource not allowed: %s", gvk.String()) } + // Skip validators if disabled or if this is SelfSubjectAccessReview (used by RBAC validator) + skipValidation := !rt.validationEnabled || (gvr.Group == "authorization.k8s.io" && gvr.Resource == "selfsubjectaccessreviews") + if skipValidation { + return rt.delegate.RoundTrip(req) + } + + namespace, resourceName := parseURLToNamespaceAndName(req.URL.Path) + verb := httpMethodToVerb(req.Method, req.URL.Path) + + validationReq := &api.HTTPValidationRequest{ + GVR: &gvr, + GVK: &gvk, + HTTPMethod: req.Method, + Verb: verb, + Namespace: namespace, + ResourceName: resourceName, + Path: req.URL.Path, + } + + if req.Body != nil && (req.Method == "POST" || req.Method == "PUT" || req.Method == "PATCH") { + body, readErr := io.ReadAll(req.Body) + _ = req.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("failed to read request body: %w", readErr) + } + req.Body = io.NopCloser(bytes.NewReader(body)) + validationReq.Body = body + } + + for _, v := range rt.validators { + if validationErr := v.Validate(req.Context(), validationReq); validationErr != nil { + if ve, ok := validationErr.(*api.ValidationError); ok { + klog.V(4).Infof("Validation failed [%s]: %v", v.Name(), ve) + } + return nil, validationErr + } + } + return rt.delegate.RoundTrip(req) } @@ -102,3 +184,79 @@ func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) { } return gvr, true } + +func parseURLToNamespaceAndName(path string) (namespace, name string) { + parts := strings.Split(strings.Trim(path, "/"), "/") + + for i, part := range parts { + if part == "namespaces" && i+1 < len(parts) { + namespace = parts[i+1] + break + } + } + + resourceIdx := findResourceTypeIndex(parts) + if resourceIdx >= 0 && resourceIdx+1 < len(parts) { + name = parts[resourceIdx+1] + } + + return namespace, name +} + +func findResourceTypeIndex(parts []string) int { + if len(parts) == 0 { + return -1 + } + + switch parts[0] { + case "api": + if len(parts) < 3 { + return -1 + } + if parts[2] == "namespaces" && len(parts) > 4 { + return 4 + } + return 2 + case "apis": + if len(parts) < 4 { + return -1 + } + if parts[3] == "namespaces" && len(parts) > 5 { + return 5 + } + return 3 + } + return -1 +} + +func httpMethodToVerb(method, path string) string { + switch method { + case "GET": + if isCollectionPath(path) { + return "list" + } + return "get" + case "POST": + return "create" + case "PUT": + return "update" + case "PATCH": + return "patch" + case "DELETE": + if isCollectionPath(path) { + return "deletecollection" + } + return "delete" + default: + return strings.ToLower(method) + } +} + +func isCollectionPath(path string) bool { + parts := strings.Split(strings.Trim(path, "/"), "/") + resourceIdx := findResourceTypeIndex(parts) + if resourceIdx < 0 { + return false + } + return resourceIdx == len(parts)-1 +} diff --git a/pkg/kubernetes/accesscontrol_round_tripper_test.go b/pkg/kubernetes/accesscontrol_round_tripper_test.go index c8a5de34a..facfa5bbd 100644 --- a/pkg/kubernetes/accesscontrol_round_tripper_test.go +++ b/pkg/kubernetes/accesscontrol_round_tripper_test.go @@ -287,7 +287,8 @@ func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDeniedAPIResources( s.Error(err) s.Nil(resp) s.False(delegateCalled, "Expected delegate not to be called when RESTMapper fails") - s.Contains(err.Error(), "failed to make request") + s.Contains(err.Error(), "RESOURCE_NOT_FOUND") + s.Contains(err.Error(), "does not exist in the cluster") }) } diff --git a/pkg/kubernetes/auth.go b/pkg/kubernetes/auth.go new file mode 100644 index 000000000..311b9285e --- /dev/null +++ b/pkg/kubernetes/auth.go @@ -0,0 +1,54 @@ +package kubernetes + +import ( + "context" + + authv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" + "k8s.io/klog/v2" +) + +// CanI checks if the current identity can perform verb on resource. +// Uses SelfSubjectAccessReview to pre-check RBAC permissions. +func CanI( + ctx context.Context, + authClient authv1client.AuthorizationV1Interface, + gvr *schema.GroupVersionResource, + namespace, resourceName, verb string, +) (bool, error) { + if authClient == nil { + return true, nil + } + + accessReview := &authv1.SelfSubjectAccessReview{ + Spec: authv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authv1.ResourceAttributes{ + Namespace: namespace, + Verb: verb, + Group: gvr.Group, + Version: gvr.Version, + Resource: gvr.Resource, + Name: resourceName, + }, + }, + } + + response, err := authClient.SelfSubjectAccessReviews().Create(ctx, accessReview, metav1.CreateOptions{}) + if err != nil { + return false, err + } + + if klog.V(5).Enabled() { + if response.Status.Allowed { + klog.V(5).Infof("RBAC check: allowed %s on %s/%s in %s", + verb, gvr.Group, gvr.Resource, namespace) + } else { + klog.V(5).Infof("RBAC check: denied %s on %s/%s in %s: %s", + verb, gvr.Group, gvr.Resource, namespace, response.Status.Reason) + } + } + + return response.Status.Allowed, nil +} diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 3cd47c3b4..f44bd46de 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -12,6 +12,7 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" @@ -50,21 +51,25 @@ type Kubernetes struct { var _ api.KubernetesClient = (*Kubernetes)(nil) -func NewKubernetes(config api.BaseConfig, clientCmdConfig clientcmd.ClientConfig, restConfig *rest.Config) (*Kubernetes, error) { +func NewKubernetes(baseConfig api.BaseConfig, clientCmdConfig clientcmd.ClientConfig, restConfig *rest.Config) (*Kubernetes, error) { k := &Kubernetes{ - config: config, + config: baseConfig, clientCmdConfig: clientCmdConfig, restConfig: rest.CopyConfig(restConfig), } if k.restConfig.UserAgent == "" { k.restConfig.UserAgent = rest.DefaultKubernetesUserAgent() } + k.restConfig.Wrap(func(original http.RoundTripper) http.RoundTripper { - return &AccessControlRoundTripper{ - delegate: original, - deniedResourcesProvider: config, - restMapperProvider: func() meta.RESTMapper { return k.restMapper }, - } + return NewAccessControlRoundTripper(AccessControlRoundTripperConfig{ + Delegate: original, + DeniedResourcesProvider: baseConfig, + RestMapperProvider: func() meta.RESTMapper { return k.restMapper }, + DiscoveryProvider: func() discovery.DiscoveryInterface { return k.discoveryClient }, + AuthClientProvider: func() authv1client.AuthorizationV1Interface { return k.AuthorizationV1() }, + ValidationEnabled: baseConfig.IsValidationEnabled(), + }) }) k.restConfig.Wrap(func(original http.RoundTripper) http.RoundTripper { return &UserAgentRoundTripper{delegate: original} diff --git a/pkg/kubernetes/rbac_validator.go b/pkg/kubernetes/rbac_validator.go new file mode 100644 index 000000000..450330f5c --- /dev/null +++ b/pkg/kubernetes/rbac_validator.go @@ -0,0 +1,52 @@ +package kubernetes + +import ( + "context" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" + "k8s.io/klog/v2" +) + +// RBACValidator pre-checks RBAC permissions before execution. +type RBACValidator struct { + authClientProvider func() authv1client.AuthorizationV1Interface +} + +// NewRBACValidator creates a new RBAC validator. +func NewRBACValidator(authClientProvider func() authv1client.AuthorizationV1Interface) *RBACValidator { + return &RBACValidator{ + authClientProvider: authClientProvider, + } +} + +func (v *RBACValidator) Name() string { + return "rbac" +} + +func (v *RBACValidator) Validate(ctx context.Context, req *api.HTTPValidationRequest) error { + if req.GVR == nil || req.Verb == "" { + return nil + } + + authClient := v.authClientProvider() + if authClient == nil { + return nil + } + + allowed, err := CanI(ctx, authClient, req.GVR, req.Namespace, req.ResourceName, req.Verb) + if err != nil { + klog.V(4).Infof("RBAC pre-validation failed with error: %v", err) + return nil + } + + if !allowed { + return api.NewPermissionDeniedError( + req.Verb, + api.FormatResourceName(req.GVR), + req.Namespace, + ) + } + + return nil +} diff --git a/pkg/kubernetes/rbac_validator_test.go b/pkg/kubernetes/rbac_validator_test.go new file mode 100644 index 000000000..28328bbc6 --- /dev/null +++ b/pkg/kubernetes/rbac_validator_test.go @@ -0,0 +1,155 @@ +package kubernetes + +import ( + "context" + "errors" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/stretchr/testify/suite" + authv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" + "k8s.io/client-go/rest" +) + +type mockSelfSubjectAccessReviewInterface struct { + allowed bool + err error +} + +func (m *mockSelfSubjectAccessReviewInterface) Create(ctx context.Context, review *authv1.SelfSubjectAccessReview, opts metav1.CreateOptions) (*authv1.SelfSubjectAccessReview, error) { + if m.err != nil { + return nil, m.err + } + review.Status.Allowed = m.allowed + return review, nil +} + +type mockAuthorizationV1Interface struct { + authv1client.AuthorizationV1Interface + selfSubjectAccessReview *mockSelfSubjectAccessReviewInterface +} + +func (m *mockAuthorizationV1Interface) RESTClient() rest.Interface { + return nil +} + +func (m *mockAuthorizationV1Interface) SelfSubjectAccessReviews() authv1client.SelfSubjectAccessReviewInterface { + return m.selfSubjectAccessReview +} + +type RBACValidatorTestSuite struct { + suite.Suite +} + +func (s *RBACValidatorTestSuite) TestName() { + v := NewRBACValidator(nil) + s.Equal("rbac", v.Name()) +} + +func (s *RBACValidatorTestSuite) TestValidate() { + testCases := []struct { + name string + req *api.HTTPValidationRequest + authClient authv1client.AuthorizationV1Interface + expectError bool + errorCode api.ValidationErrorCode + }{ + { + name: "nil GVR passes validation", + req: &api.HTTPValidationRequest{GVR: nil, Verb: "get"}, + authClient: nil, + expectError: false, + }, + { + name: "empty verb passes validation", + req: &api.HTTPValidationRequest{ + GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + Verb: "", + }, + authClient: nil, + expectError: false, + }, + { + name: "nil auth client passes validation", + req: &api.HTTPValidationRequest{ + GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + Verb: "get", + }, + authClient: nil, + expectError: false, + }, + { + name: "allowed action passes validation", + req: &api.HTTPValidationRequest{ + GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + Verb: "get", + Namespace: "default", + }, + authClient: &mockAuthorizationV1Interface{ + selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{allowed: true}, + }, + expectError: false, + }, + { + name: "denied action fails validation", + req: &api.HTTPValidationRequest{ + GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, + Verb: "delete", + Namespace: "kube-system", + }, + authClient: &mockAuthorizationV1Interface{ + selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{allowed: false}, + }, + expectError: true, + errorCode: api.ErrorCodePermissionDenied, + }, + { + name: "auth client error passes validation", + req: &api.HTTPValidationRequest{ + GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, + Verb: "get", + Namespace: "default", + }, + authClient: &mockAuthorizationV1Interface{ + selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{err: errors.New("connection refused")}, + }, + expectError: false, + }, + { + name: "cluster-scoped resource denied", + req: &api.HTTPValidationRequest{ + GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"}, + Verb: "delete", + Namespace: "", + }, + authClient: &mockAuthorizationV1Interface{ + selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{allowed: false}, + }, + expectError: true, + errorCode: api.ErrorCodePermissionDenied, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + v := NewRBACValidator(func() authv1client.AuthorizationV1Interface { return tc.authClient }) + err := v.Validate(context.Background(), tc.req) + + if tc.expectError { + s.Error(err) + if ve, ok := err.(*api.ValidationError); ok { + s.Equal(tc.errorCode, ve.Code) + } + } else { + s.NoError(err) + } + }) + } +} + +func TestRBACValidator(t *testing.T) { + suite.Run(t, new(RBACValidatorTestSuite)) +} diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 513a51be2..3769361bd 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -11,7 +11,6 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/containers/kubernetes-mcp-server/pkg/version" - authv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" @@ -230,19 +229,6 @@ func (c *Core) supportsGroupVersion(groupVersion string) bool { } func (c *Core) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool { - accessReviews := c.AuthorizationV1().SelfSubjectAccessReviews() - response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{ - Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{ - Namespace: namespace, - Verb: verb, - Group: gvr.Group, - Version: gvr.Version, - Resource: gvr.Resource, - }}, - }, metav1.CreateOptions{}) - if err != nil { - // TODO: maybe return the error too - return false - } - return response.Status.Allowed + allowed, _ := CanI(ctx, c.AuthorizationV1(), gvr, namespace, "", verb) + return allowed } diff --git a/pkg/kubernetes/schema_validator.go b/pkg/kubernetes/schema_validator.go new file mode 100644 index 000000000..fabd48676 --- /dev/null +++ b/pkg/kubernetes/schema_validator.go @@ -0,0 +1,130 @@ +package kubernetes + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "k8s.io/client-go/discovery" + "k8s.io/klog/v2" + kubectlopenapi "k8s.io/kubectl/pkg/util/openapi" + kubectlvalidation "k8s.io/kubectl/pkg/validation" +) + +const schemaCacheTTL = 15 * time.Minute + +// SchemaValidator validates resource manifests against the OpenAPI schema. +type SchemaValidator struct { + discoveryClientProvider func() discovery.DiscoveryInterface + kubectlValidator kubectlvalidation.Schema + validatorMu sync.Mutex + validatorCachedAt time.Time +} + +// NewSchemaValidator creates a new schema validator. +func NewSchemaValidator(discoveryClientProvider func() discovery.DiscoveryInterface) *SchemaValidator { + return &SchemaValidator{ + discoveryClientProvider: discoveryClientProvider, + } +} + +func (v *SchemaValidator) Name() string { + return "schema" +} + +func (v *SchemaValidator) Validate(ctx context.Context, req *api.HTTPValidationRequest) error { + if req.GVK == nil || len(req.Body) == 0 { + return nil + } + + // Only validate for create/update operations (exclude patch as partial bodies cause false positives) + if req.Verb != "create" && req.Verb != "update" { + return nil + } + + validator, err := v.getValidator() + if err != nil { + klog.V(4).Infof("Failed to get schema validator: %v", err) + return nil + } + + if validator == nil { + return nil + } + + err = validator.ValidateBytes(req.Body) + if err != nil { + // Check if this is a parsing error (e.g., binary data that can't be parsed as YAML) + // In that case, skip validation rather than blocking the request + errMsg := err.Error() + if strings.Contains(errMsg, "yaml:") || strings.Contains(errMsg, "json:") { + klog.V(4).Infof("Schema validation skipped due to parsing error: %v", err) + return nil + } + return convertKubectlValidationError(err) + } + + return nil +} + +// openAPIResourcesAdapter adapts CachedOpenAPIParser to OpenAPIResourcesGetter interface. +type openAPIResourcesAdapter struct { + parser *kubectlopenapi.CachedOpenAPIParser +} + +func (a *openAPIResourcesAdapter) OpenAPISchema() (kubectlopenapi.Resources, error) { + return a.parser.Parse() +} + +func (v *SchemaValidator) getValidator() (kubectlvalidation.Schema, error) { + v.validatorMu.Lock() + defer v.validatorMu.Unlock() + + if v.kubectlValidator != nil && time.Since(v.validatorCachedAt) <= schemaCacheTTL { + return v.kubectlValidator, nil + } + + discoveryClient := v.discoveryClientProvider() + if discoveryClient == nil { + return nil, nil + } + + openAPIClient, ok := discoveryClient.(discovery.OpenAPISchemaInterface) + if !ok { + klog.V(4).Infof("Discovery client does not support OpenAPI schema") + return nil, nil + } + + parser := kubectlopenapi.NewOpenAPIParser(openAPIClient) + adapter := &openAPIResourcesAdapter{parser: parser} + + v.kubectlValidator = kubectlvalidation.NewSchemaValidation(adapter) + v.validatorCachedAt = time.Now() + + return v.kubectlValidator, nil +} + +func convertKubectlValidationError(err error) *api.ValidationError { + if err == nil { + return nil + } + + errMsg := err.Error() + + var field string + if strings.Contains(errMsg, "unknown field") { + if start := strings.Index(errMsg, "\""); start != -1 { + if end := strings.Index(errMsg[start+1:], "\""); end != -1 { + field = errMsg[start+1 : start+1+end] + } + } + } + + return &api.ValidationError{ + Code: api.ErrorCodeInvalidField, + Message: errMsg, + Field: field, + } +} diff --git a/pkg/kubernetes/validator_registry.go b/pkg/kubernetes/validator_registry.go new file mode 100644 index 000000000..a6457c69e --- /dev/null +++ b/pkg/kubernetes/validator_registry.go @@ -0,0 +1,41 @@ +package kubernetes + +import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" + "k8s.io/client-go/discovery" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" +) + +// ValidatorProviders holds the providers needed to create validators. +type ValidatorProviders struct { + Discovery func() discovery.DiscoveryInterface + AuthClient func() authv1client.AuthorizationV1Interface +} + +// ValidatorFactory creates a validator given the providers. +type ValidatorFactory func(ValidatorProviders) api.HTTPValidator + +var validatorFactories []ValidatorFactory + +// RegisterValidator adds a validator factory to the registry. +func RegisterValidator(factory ValidatorFactory) { + validatorFactories = append(validatorFactories, factory) +} + +// CreateValidators creates all registered validators with the given providers. +func CreateValidators(providers ValidatorProviders) []api.HTTPValidator { + validators := make([]api.HTTPValidator, 0, len(validatorFactories)) + for _, factory := range validatorFactories { + validators = append(validators, factory(providers)) + } + return validators +} + +func init() { + RegisterValidator(func(p ValidatorProviders) api.HTTPValidator { + return NewSchemaValidator(p.Discovery) + }) + RegisterValidator(func(p ValidatorProviders) api.HTTPValidator { + return NewRBACValidator(p.AuthClient) + }) +} diff --git a/pkg/kubernetes/validator_registry_test.go b/pkg/kubernetes/validator_registry_test.go new file mode 100644 index 000000000..8fab8b4f1 --- /dev/null +++ b/pkg/kubernetes/validator_registry_test.go @@ -0,0 +1,49 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "k8s.io/client-go/discovery" + authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" +) + +type ValidatorRegistryTestSuite struct { + suite.Suite +} + +func (s *ValidatorRegistryTestSuite) TestCreateValidatorsReturnsRegisteredValidators() { + providers := ValidatorProviders{ + Discovery: func() discovery.DiscoveryInterface { return nil }, + AuthClient: func() authv1client.AuthorizationV1Interface { return nil }, + } + + validators := CreateValidators(providers) + + s.GreaterOrEqual(len(validators), 2, "Expected at least 2 validators (schema, rbac)") + + names := make(map[string]bool) + for _, v := range validators { + names[v.Name()] = true + } + + s.True(names["schema"], "Expected schema validator to be registered") + s.True(names["rbac"], "Expected rbac validator to be registered") +} + +func (s *ValidatorRegistryTestSuite) TestCreateValidatorsWithNilProviders() { + providers := ValidatorProviders{ + Discovery: nil, + AuthClient: nil, + } + + // Should not panic + s.NotPanics(func() { + validators := CreateValidators(providers) + s.NotEmpty(validators, "Expected validators to be created even with nil providers") + }) +} + +func TestValidatorRegistry(t *testing.T) { + suite.Run(t, new(ValidatorRegistryTestSuite)) +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 1e5ac1b6d..30fa77c8e 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -110,6 +110,7 @@ func NewServer(configuration Configuration, targetProvider internalk8s.Provider) s.server.AddReceivingMiddleware(userAgentPropagationMiddleware(version.BinaryName, version.Version)) s.server.AddReceivingMiddleware(toolCallLoggingMiddleware) s.server.AddReceivingMiddleware(s.metricsMiddleware()) + err = s.reloadToolsets() if err != nil { return nil, err