diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index 833346eb1e5..4073aac3387 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -1986,7 +1986,7 @@ func TestCacheMiddleware_AuthErrorResponse(t *testing.T) { ctx := context.Background() expectedResponse := `{"kind":"Status","apiVersion":"v1","metadata":{"resourceVersion":""},` + - `"message":"resource is forbidden: User \"system:serviceaccount:default:test\" cannot get resource ` + + `"message":"resource is forbidden: User \"system:serviceaccount:default:test\" cannot list resource ` + `\"resource\" in API group \"\" at the cluster scope","reason":"Forbidden","details":{"kind":"resource"},` + `"code":403}` diff --git a/backend/pkg/k8cache/authorization.go b/backend/pkg/k8cache/authorization.go index 54e2553094d..213ec129244 100644 --- a/backend/pkg/k8cache/authorization.go +++ b/backend/pkg/k8cache/authorization.go @@ -41,6 +41,8 @@ import ( // it becomes eligible for eviction. const clientsetTTL = 10 * time.Minute +const unknownVerb = "unknown" + // janitorInterval is how often the background goroutine sweeps the // cache for expired entries. const janitorInterval = 5 * time.Minute @@ -263,33 +265,131 @@ func GetClientSet(k *kubeconfig.Context, token string) (*kubernetes.Clientset, e return cs, err } -// GetKindAndVerb extracts the Kubernetes resource kind and intended verb (e.g., get, watch) -// from the incoming HTTP request. -func GetKindAndVerb(r *http.Request) (string, string) { - apiPath, ok := mux.Vars(r)["api"] - if !ok || apiPath == "" { - return "", "unknown" +type apiResourceRequest struct { + group string + version string + namespace string + resource string + name string + subresource string +} + +func isNamespaceSubresource(subresource string) bool { + return subresource == "status" || subresource == "finalize" +} + +func parseAPIResourceRequest(apiPath string) (apiResourceRequest, bool) { + parts := strings.Split(strings.Trim(apiPath, "/"), "/") + if len(parts) < 3 { + return apiResourceRequest{}, false } - parts := strings.Split(apiPath, "/") - last := parts[len(parts)-1] + request := apiResourceRequest{} + resourceIndex := 0 - var kubeVerb string + switch parts[0] { + case apiPathSegment: + request.version = parts[1] + resourceIndex = 2 + case apisPathSegment: + if len(parts) < 4 { + return apiResourceRequest{}, false + } + + request.group = parts[1] + request.version = parts[2] + resourceIndex = 3 + default: + return apiResourceRequest{}, false + } + + if parts[resourceIndex] == namespacePathSegment && + len(parts) == resourceIndex+3 && + isNamespaceSubresource(parts[resourceIndex+2]) { + request.resource = namespacePathSegment + request.name = parts[resourceIndex+1] + request.subresource = parts[resourceIndex+2] + + return request, true + } + + if parts[resourceIndex] == namespacePathSegment && len(parts) > resourceIndex+2 { + request.namespace = parts[resourceIndex+1] + resourceIndex += 2 + } + + request.resource = parts[resourceIndex] + if len(parts) > resourceIndex+1 { + request.name = parts[resourceIndex+1] + } + + if len(parts) > resourceIndex+2 { + request.subresource = parts[resourceIndex+2] + } + + return request, true +} +func getKubeVerb(r *http.Request, request apiResourceRequest) string { isWatch, _ := strconv.ParseBool(r.URL.Query().Get("watch")) switch r.Method { case "GET": if isWatch { - kubeVerb = "watch" - } else { - kubeVerb = "get" + return "watch" } + + if request.name == "" { + return "list" + } + + return "get" default: - kubeVerb = "unknown" + return unknownVerb + } +} + +// GetKindAndVerb extracts the Kubernetes resource kind and intended verb (e.g., get, watch) +// from the incoming HTTP request. +func GetKindAndVerb(r *http.Request) (string, string) { + apiPath, ok := mux.Vars(r)["api"] + if !ok || apiPath == "" { + return "", unknownVerb + } + + request, ok := parseAPIResourceRequest(apiPath) + if !ok { + return "", unknownVerb } - return last, kubeVerb + return request.resource, getKubeVerb(r, request) +} + +func getResourceAttributes(r *http.Request) (*authorizationv1.ResourceAttributes, error) { + apiPath, ok := mux.Vars(r)["api"] + if !ok || apiPath == "" { + return nil, fmt.Errorf("could not determine resource or verb from request") + } + + request, ok := parseAPIResourceRequest(apiPath) + if !ok { + return nil, fmt.Errorf("could not determine resource or verb from request") + } + + kubeVerb := getKubeVerb(r, request) + if request.resource == "" || kubeVerb == "" { + return nil, fmt.Errorf("could not determine resource or verb from request") + } + + return &authorizationv1.ResourceAttributes{ + Group: request.group, + Version: request.version, + Resource: request.resource, + Subresource: request.subresource, + Namespace: request.namespace, + Name: request.name, + Verb: kubeVerb, + }, nil } // IsAllowed checks the user's permission to access the resource. @@ -307,17 +407,14 @@ func IsAllowed( return false, err } - last, kubeVerb := GetKindAndVerb(r) - if last == "" || kubeVerb == "" { - return false, fmt.Errorf("could not determine resource or verb from request") + resourceAttributes, err := getResourceAttributes(r) + if err != nil { + return false, err } review := &authorizationv1.SelfSubjectAccessReview{ Spec: authorizationv1.SelfSubjectAccessReviewSpec{ - ResourceAttributes: &authorizationv1.ResourceAttributes{ - Resource: last, - Verb: kubeVerb, - }, + ResourceAttributes: resourceAttributes, }, } diff --git a/backend/pkg/k8cache/authorization_internal_test.go b/backend/pkg/k8cache/authorization_internal_test.go new file mode 100644 index 00000000000..ea9812d6abd --- /dev/null +++ b/backend/pkg/k8cache/authorization_internal_test.go @@ -0,0 +1,145 @@ +// Copyright 2025 The Kubernetes Authors. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8cache + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +type resourceAttributesTestCase struct { + name string + urlPath string + apiPath string + expectedGroup string + expectedVersion string + expectedNamespace string + expectedResource string + expectedName string + expectedSubresource string + expectedVerb string +} + +func TestGetResourceAttributesForNamedResource(t *testing.T) { + tests := []resourceAttributesTestCase{ + { + name: "named namespaced core resource", + urlPath: "/clusters/named-resource-test/api/v1/namespaces/default/pods/nginx", + apiPath: "api/v1/namespaces/default/pods/nginx", + expectedVersion: "v1", + expectedNamespace: "default", + expectedResource: "pods", + expectedName: "nginx", + expectedVerb: "get", + }, + { + name: "named resource with subresource", + urlPath: "/clusters/named-resource-test/api/v1/namespaces/default/pods/nginx/log", + apiPath: "api/v1/namespaces/default/pods/nginx/log", + expectedVersion: "v1", + expectedNamespace: "default", + expectedResource: "pods", + expectedName: "nginx", + expectedSubresource: "log", + expectedVerb: "get", + }, + { + name: "named namespaced API group resource", + urlPath: "/clusters/named-resource-test/apis/apps/v1/namespaces/default/deployments/frontend", + apiPath: "apis/apps/v1/namespaces/default/deployments/frontend", + expectedGroup: "apps", + expectedVersion: "v1", + expectedNamespace: "default", + expectedResource: "deployments", + expectedName: "frontend", + expectedVerb: "get", + }, + { + name: "namespaced collection", + urlPath: "/clusters/named-resource-test/api/v1/namespaces/default/pods", + apiPath: "api/v1/namespaces/default/pods", + expectedVersion: "v1", + expectedNamespace: "default", + expectedResource: "pods", + expectedVerb: "list", + }, + } + + runResourceAttributesTests(t, tests) +} + +func TestGetResourceAttributesForNamespaceResource(t *testing.T) { + tests := []resourceAttributesTestCase{ + { + name: "named namespace", + urlPath: "/clusters/named-resource-test/api/v1/namespaces/default", + apiPath: "api/v1/namespaces/default", + expectedVersion: "v1", + expectedResource: "namespaces", + expectedName: "default", + expectedVerb: "get", + }, + { + name: "namespace status subresource", + urlPath: "/clusters/named-resource-test/api/v1/namespaces/default/status", + apiPath: "api/v1/namespaces/default/status", + expectedVersion: "v1", + expectedResource: "namespaces", + expectedName: "default", + expectedSubresource: "status", + expectedVerb: "get", + }, + { + name: "namespace finalize subresource", + urlPath: "/clusters/named-resource-test/api/v1/namespaces/default/finalize", + apiPath: "api/v1/namespaces/default/finalize", + expectedVersion: "v1", + expectedResource: "namespaces", + expectedName: "default", + expectedSubresource: "finalize", + expectedVerb: "get", + }, + } + + runResourceAttributesTests(t, tests) +} + +func runResourceAttributesTests(t *testing.T, tests []resourceAttributesTestCase) { + t.Helper() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.urlPath, nil) + req = mux.SetURLVars(req, map[string]string{ + "api": tc.apiPath, + }) + + attributes, err := getResourceAttributes(req) + assert.NoError(t, err) + + assert.Equal(t, tc.expectedGroup, attributes.Group) + assert.Equal(t, tc.expectedVersion, attributes.Version) + assert.Equal(t, tc.expectedNamespace, attributes.Namespace) + assert.Equal(t, tc.expectedResource, attributes.Resource) + assert.Equal(t, tc.expectedName, attributes.Name) + assert.Equal(t, tc.expectedSubresource, attributes.Subresource) + assert.Equal(t, tc.expectedVerb, attributes.Verb) + }) + } +} diff --git a/backend/pkg/k8cache/authorization_test.go b/backend/pkg/k8cache/authorization_test.go index bcde4a75c98..c24a6a96011 100644 --- a/backend/pkg/k8cache/authorization_test.go +++ b/backend/pkg/k8cache/authorization_test.go @@ -125,7 +125,7 @@ func TestGetKindAndVerb(t *testing.T) { urlPath: "/api/v1/pods", muxVars: map[string]string{"api": "api/v1/pods"}, expectedKind: "pods", - expectedVerb: "get", + expectedVerb: "list", }, { name: "Named API with trailing slash", @@ -133,7 +133,7 @@ func TestGetKindAndVerb(t *testing.T) { urlPath: "/apis/apps/v1/deployments/", muxVars: map[string]string{"api": "apis/apps/v1/deployments"}, expectedKind: "deployments", - expectedVerb: "get", + expectedVerb: "list", }, { name: "POST request", @@ -157,7 +157,7 @@ func TestGetKindAndVerb(t *testing.T) { urlPath: "/apis/apps/v1/deployments?watch=0", muxVars: map[string]string{"api": "apis/apps/v1/deployments"}, expectedKind: "deployments", - expectedVerb: "get", + expectedVerb: "list", }, } for _, tc := range tests { @@ -171,6 +171,91 @@ func TestGetKindAndVerb(t *testing.T) { } } +type getKindAndVerbTestCase struct { + name string + urlPath string + muxVars map[string]string + expectedKind string + expectedVerb string +} + +func TestGetKindAndVerbNamedResources(t *testing.T) { + tests := []getKindAndVerbTestCase{ + { + name: "Core API named namespaced resource", + urlPath: "/api/v1/namespaces/default/pods/nginx", + muxVars: map[string]string{"api": "api/v1/namespaces/default/pods/nginx"}, + expectedKind: "pods", + expectedVerb: "get", + }, + { + name: "Named API named namespaced resource", + urlPath: "/apis/apps/v1/namespaces/default/deployments/frontend", + muxVars: map[string]string{"api": "apis/apps/v1/namespaces/default/deployments/frontend"}, + expectedKind: "deployments", + expectedVerb: "get", + }, + { + name: "Core API named resource subresource", + urlPath: "/api/v1/namespaces/default/pods/nginx/log", + muxVars: map[string]string{"api": "api/v1/namespaces/default/pods/nginx/log"}, + expectedKind: "pods", + expectedVerb: "get", + }, + } + + runGetKindAndVerbTests(t, tests) +} + +func TestGetKindAndVerbNamespaceResources(t *testing.T) { + tests := []getKindAndVerbTestCase{ + { + name: "Core API namespace list", + urlPath: "/api/v1/namespaces", + muxVars: map[string]string{"api": "api/v1/namespaces"}, + expectedKind: "namespaces", + expectedVerb: "list", + }, + { + name: "Core API named namespace", + urlPath: "/api/v1/namespaces/default", + muxVars: map[string]string{"api": "api/v1/namespaces/default"}, + expectedKind: "namespaces", + expectedVerb: "get", + }, + { + name: "Core API namespace status subresource", + urlPath: "/api/v1/namespaces/default/status", + muxVars: map[string]string{"api": "api/v1/namespaces/default/status"}, + expectedKind: "namespaces", + expectedVerb: "get", + }, + { + name: "Core API namespace finalize subresource", + urlPath: "/api/v1/namespaces/default/finalize", + muxVars: map[string]string{"api": "api/v1/namespaces/default/finalize"}, + expectedKind: "namespaces", + expectedVerb: "get", + }, + } + + runGetKindAndVerbTests(t, tests) +} + +func runGetKindAndVerbTests(t *testing.T, tests []getKindAndVerbTestCase) { + t.Helper() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.urlPath, nil) + req = mux.SetURLVars(req, tc.muxVars) + kind, verb := k8cache.GetKindAndVerb(req) + assert.Equal(t, tc.expectedKind, kind) + assert.Equal(t, tc.expectedVerb, verb) + }) + } +} + func TestIsAllowed(t *testing.T) { tests := []struct { name string diff --git a/backend/pkg/k8cache/cacheStore.go b/backend/pkg/k8cache/cacheStore.go index 80e971be3f2..4d30e0a4b73 100644 --- a/backend/pkg/k8cache/cacheStore.go +++ b/backend/pkg/k8cache/cacheStore.go @@ -36,8 +36,9 @@ import ( ) const ( - apiPathSegment = "api" - apisPathSegment = "apis" + apiPathSegment = "api" + apisPathSegment = "apis" + namespacePathSegment = "namespaces" ) // CachedResponseData stores information such as StatusCode, Headers, and Body. @@ -133,7 +134,7 @@ func ExtractNamespace(rawURL string) (string, string) { } for i := 0; i < n-1; i++ { - if urls[i] == "namespaces" { + if urls[i] == namespacePathSegment { namespace = urls[i+1] break }