From e43fc23af8745054c352b17b7535a11bca888db2 Mon Sep 17 00:00:00 2001 From: Harshita Yadav Date: Sun, 17 May 2026 21:10:43 +0530 Subject: [PATCH] backend: k8cache: Fix named resource authorization Parse proxied Kubernetes API paths by structure before building cache authorization SelfSubjectAccessReviews. This keeps named object requests from using the object name as the resource and preserves namespace, name, version, group, and subresource attributes. --- backend/cmd/headlamp_test.go | 2 +- backend/pkg/k8cache/authorization.go | 139 ++++++++++++++--- .../k8cache/authorization_internal_test.go | 145 ++++++++++++++++++ backend/pkg/k8cache/authorization_test.go | 91 ++++++++++- backend/pkg/k8cache/cacheStore.go | 7 +- 5 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 backend/pkg/k8cache/authorization_internal_test.go 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 }