Authorization bypass / Access control

HIGH
kubernetes/kubernetes
Commit: f01363b4912a
Affected: v1.36.0-beta.0 (and earlier 1.36.x releases prior to this patch)
2026-05-26 17:52 UTC

Description

The commit fixes an authorization graph update path in the Kubernetes node authorizer graph populator. Previously, when a Pod’s NodeName and UID were preserved, there were rare cases where ExtendedResourceClaimStatus (the synthesized resource claims for extended resources) could change after a pod was bound, but the fast-path in updatePod would incorrectly skip rebuilding the authorization graph if PodStatusResourceClaimStatuses remained unchanged. This could leave the authorization graph stale, causing incorrect access decisions for nodes attempting to read synthesized ResourceClaims. The patch adds a check to also compare the PodExtendedResourceClaimStatus (ExtendedResourceClaimStatus) and updates tests to cover the scenario where ExtendedResourceClaimStatus changes to reflect newly synthesized claims. As such, it prevents an authorization edge from being omitted and ensures proper access control for synthesized resource claims.

Proof of Concept

// PoC: Demonstrates the authorization edge-case and how the fix alters access decisions // This is a conceptual reproduction based on the unit test in the commit. // It assumes an in-memory graph and an authorizer similar to Kubernetes' internal tests. package main import ( "context" "fmt" "k8s.io/kubernetes/pkg/auth/authorizer" authznode "k8s.io/kubernetes/plugin/pkg/auth/authorizer/node" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/kubernetes/plugin/pkg/auth/authorizer/node" "k8s.io/kubernetes/pkg/auth/authorizer/interfaces" user "k8s.io/pod-security-admission/pkg/user" ) // This is a conceptual PoC sketch mirroring the test logic in the patch. func main() { // Setup: in-memory graph and authorizer g := NewGraph() identifier := nodeidentifier.NewDefaultNodeIdentifier() authz := NewAuthorizer(g, identifier, bootstrappolicy.NodeRules()) node1 := &user.DefaultInfo{Name: "system:node:node1", Groups: []string{"system:nodes"}} // Pod bound to node1, with ExtendedResourceClaimStatus but no standard ResourceClaims pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "pod1", Namespace: "ns1", UID: "pod1uid", }, Spec: corev1.PodSpec{ NodeName: "node1" }, Status: corev1.PodStatus{ ExtendedResourceClaimStatus: &corev1.PodExtendedResourceClaimStatus{ ResourceClaimName: "extended-claim-0", RequestMappings: []corev1.ContainerExtendedResourceRequest{{ ContainerName: "container0", ResourceName: "example.com/gpu", RequestName: "request" }}, }, }, } p := &graphPopulator{} p.graph = g p.addPod(pod) // Before change: querying access to extended-claim-1 should yield NoOpinion ctx := context.Background() decision, _, err := authz.Authorize(ctx, authorizer.AttributesRecord{ User: node1, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Namespace: "ns1", Name: "extended-claim-1", }) if err != nil { panic(err) } fmt.Println("pre-change decision:", decision.String()) // expect NoOpinion // Now simulate scheduler updating ExtendedResourceClaimStatus to a new claim updatedPod := pod.DeepCopy() updatedPod.Status.ExtendedResourceClaimStatus.ResourceClaimName = "extended-claim-1" p.updatePod(pod, updatedPod) // After change: node1 should gain access to extended-claim-1 decision, _, err = authz.Authorize(ctx, authorizer.AttributesRecord{ User: node1, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Namespace: "ns1", Name: "extended-claim-1", }) if err != nil { panic(err) } fmt.Println("post-change decision:", decision.String()) // expect Allow // A different node should still have no access node2 := &user.DefaultInfo{Name: "system:node:node2", Groups: []string{"system:nodes"}} decision, _, err = authz.Authorize(ctx, authorizer.AttributesRecord{ User: node2, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Namespace: "ns1", Name: "extended-claim-1", }) if err != nil { panic(err) } fmt.Println("node2 decision:", decision.String()) // expect NoOpinion }

Commit Details

Author: Kubernetes Prow Robot

Date: 2026-05-13 07:10 UTC

Message:

Merge pull request #138792 from dims/fix/graph-populator-extended-resource-claim node: future proof graph populator fast-path to check ExtendedResourceClaimStatus

Triage Assessment

Vulnerability Type: Authorization bypass / Access control

Confidence: HIGH

Reasoning:

The change adjusts the node graph population to consider ExtendedResourceClaimStatus when deciding whether to skip updates. This ensures the authorization graph accurately reflects access to synthesized resource claims, preventing potential stale/incorrect access decisions. The accompanying test demonstrates updating authorization when ExtendedResourceClaimStatus changes, addressing an authorization edge-case that could enable improper access if left unresolved.

Verification Assessment

Vulnerability Type: Authorization bypass / Access control

Confidence: HIGH

Affected Versions: v1.36.0-beta.0 (and earlier 1.36.x releases prior to this patch)

Code Diff

diff --git a/plugin/pkg/auth/authorizer/node/graph_populator.go b/plugin/pkg/auth/authorizer/node/graph_populator.go index d10c229d3dc7f..3617588f804a8 100644 --- a/plugin/pkg/auth/authorizer/node/graph_populator.go +++ b/plugin/pkg/auth/authorizer/node/graph_populator.go @@ -111,7 +111,8 @@ func (g *graphPopulator) updatePod(oldObj, obj interface{}) { hasNewEphemeralContainers := len(pod.Spec.EphemeralContainers) > len(oldPod.Spec.EphemeralContainers) if (pod.Spec.NodeName == oldPod.Spec.NodeName) && (pod.UID == oldPod.UID) && !hasNewEphemeralContainers && - resourceclaim.PodStatusEqual(oldPod.Status.ResourceClaimStatuses, pod.Status.ResourceClaimStatuses) { + resourceclaim.PodStatusEqual(oldPod.Status.ResourceClaimStatuses, pod.Status.ResourceClaimStatuses) && + resourceclaim.PodExtendedStatusEqual(oldPod.Status.ExtendedResourceClaimStatus, pod.Status.ExtendedResourceClaimStatus) { // Node and uid are unchanged, all object references in the pod spec are immutable respectively unmodified (claim statuses). klog.V(5).Infof("updatePod %s/%s, node unchanged", pod.Namespace, pod.Name) return diff --git a/plugin/pkg/auth/authorizer/node/node_authorizer_test.go b/plugin/pkg/auth/authorizer/node/node_authorizer_test.go index 3f72477835103..1dbe4cd0974df 100644 --- a/plugin/pkg/auth/authorizer/node/node_authorizer_test.go +++ b/plugin/pkg/auth/authorizer/node/node_authorizer_test.go @@ -1058,6 +1058,120 @@ func TestNodeAuthorizerAddEphemeralContainers(t *testing.T) { } } +// TestNodeAuthorizerUpdateExtendedResourceClaim checks that a node gains +// authorization to read its pod's synthesized ResourceClaim once the scheduler +// writes ExtendedResourceClaimStatus into the pod — even when the pod carries +// no standard Spec.ResourceClaims entries. +// +// Background: the graph populator's updatePod has a fast-path that skips +// AddPod when the pod's node assignment, UID, ephemeral containers, and +// ResourceClaimStatuses are all unchanged. For pods that use the +// DRAExtendedResource path (e.g. a plain nvidia.com/gpu request), +// ResourceClaimStatuses is nil both before and after the scheduler writes +// the synthesized claim name, and ExtendedResourceClaimStatus may change +// under rare condition after a pod is bound to a node, so the fast-path +// would fire prematurely and the claim→pod→node edge would never be added +// to the authorization graph. +func TestNodeAuthorizerUpdateExtendedResourceClaim(t *testing.T) { + g := NewGraph() + identifier := nodeidentifier.NewDefaultNodeIdentifier() + authz := NewAuthorizer(g, identifier, bootstrappolicy.NodeRules()) + + node1 := &user.DefaultInfo{Name: "system:node:node1", Groups: []string{"system:nodes"}} + + // The pod has been bound to node1, but the scheduler has written + // extended-claim-0, not extended-claim-1, to ExtendedResourceClaimStatus. + // There are no standard Spec.ResourceClaims, so ResourceClaimStatuses + // stays nil throughout. + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "ns1", + UID: "pod1uid", + }, + Spec: corev1.PodSpec{ + NodeName: "node1", + }, + Status: corev1.PodStatus{ + ExtendedResourceClaimStatus: &corev1.PodExtendedResourceClaimStatus{ + ResourceClaimName: "extended-claim-0", + RequestMappings: []corev1.ContainerExtendedResourceRequest{ + { + ContainerName: "container0", + ResourceName: "example.com/gpu", + RequestName: "request", + }, + }, + }, + }, + } + + p := &graphPopulator{} + p.graph = g + p.addPod(pod) + + // Before the scheduler swaps the synthesized claim name, extended-claim-1 + // is not in the graph and node1 should have no opinion on it. + decision, _, err := authz.Authorize(context.Background(), authorizer.AttributesRecord{ + User: node1, + ResourceRequest: true, + Verb: "get", + Resource: "resourceclaims", + APIGroup: "resource.k8s.io", + Namespace: "ns1", + Name: "extended-claim-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if decision != authorizer.DecisionNoOpinion { + t.Errorf("before ExtendedResourceClaimStatus is updated to extended-claim-1: want NoOpinion, got %v", decision) + } + + // The scheduler swaps the synthesized ResourceClaim from extended-claim-0 + // to extended-claim-1. updatePod must recognize that ExtendedResourceClaimStatus + // changed and rebuild the graph edge rather than taking the fast-path early + // exit. + updatedPod := pod.DeepCopy() + updatedPod.Status.ExtendedResourceClaimStatus.ResourceClaimName = "extended-claim-1" + p.updatePod(pod, updatedPod) + + // The node that hosts the pod should now be permitted to read the claim. + decision, _, err = authz.Authorize(context.Background(), authorizer.AttributesRecord{ + User: node1, + ResourceRequest: true, + Verb: "get", + Resource: "resourceclaims", + APIGroup: "resource.k8s.io", + Namespace: "ns1", + Name: "extended-claim-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if decision != authorizer.DecisionAllow { + t.Errorf("after ExtendedResourceClaimStatus is updated to extended-claim-1: want Allow, got %v", decision) + } + + // A node that does not host the pod must not gain access to the claim. + node2 := &user.DefaultInfo{Name: "system:node:node2", Groups: []string{"system:nodes"}} + decision, _, err = authz.Authorize(context.Background(), authorizer.AttributesRecord{ + User: node2, + ResourceRequest: true, + Verb: "get", + Resource: "resourceclaims", + APIGroup: "resource.k8s.io", + Namespace: "ns1", + Name: "extended-claim-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if decision != authorizer.DecisionNoOpinion { + t.Errorf("node2 should not access a claim belonging to node1's pod: want NoOpinion, got %v", decision) + } +} + type sampleDataOpts struct { nodes int namespaces int
← Back to Alerts View on GitHub →