Merge pull request #134947 from aojea/dra_status_check

Fine-grained Authorization for ResourceClaim Status Updates
This commit is contained in:
Kubernetes Prow Robot
2026-03-26 22:34:18 +05:30
committed by GitHub
23 changed files with 1816 additions and 34 deletions

View File

@@ -432,7 +432,10 @@ func (c CompletedConfig) StorageProviders(client *kubernetes.Clientset) ([]contr
appsrest.StorageProvider{},
admissionregistrationrest.RESTStorageProvider{Authorizer: c.ControlPlane.Generic.Authorization.Authorizer, DiscoveryClient: client.Discovery()},
eventsrest.RESTStorageProvider{TTL: c.ControlPlane.EventTTL},
resourcerest.RESTStorageProvider{NamespaceClient: client.CoreV1().Namespaces()},
resourcerest.RESTStorageProvider{
NamespaceClient: client.CoreV1().Namespaces(),
Authorizer: c.ControlPlane.Generic.Authorization.Authorizer,
},
}
if AdditionalStorageProvidersForTests != nil {

View File

@@ -270,6 +270,16 @@ const (
// status from DRA drivers.
DRAResourceClaimDeviceStatus featuregate.Feature = "DRAResourceClaimDeviceStatus"
// owner: @aojea
// kep: http://kep.k8s.io/4817
//
// Enables fine-grained authorization checks for ResourceClaim status updates.
// Requires separate permission on resourceclaims/binding to update
// status.allocation and status.reservedFor, and per-driver permission on
// resourceclaims/driver using associated-node / arbitrary-node verb prefixes
// to update status.devices.
DRAResourceClaimGranularStatusAuthorization featuregate.Feature = "DRAResourceClaimGranularStatusAuthorization"
// owner: @nmn3m
// kep: http://kep.k8s.io/5677
//
@@ -1390,6 +1400,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate
{Version: version.MustParse("1.33"), Default: true, PreRelease: featuregate.Beta},
},
DRAResourceClaimGranularStatusAuthorization: {
{Version: version.MustParse("1.36"), Default: true, PreRelease: featuregate.Beta},
},
DRAResourcePoolStatus: {
{Version: version.MustParse("1.36"), Default: false, PreRelease: featuregate.Alpha},
},
@@ -2438,6 +2452,8 @@ var defaultKubernetesFeatureGateDependencies = map[featuregate.Feature][]feature
DRAResourceClaimDeviceStatus: {}, // Soft dependency on DynamicResourceAllocation due to on/off-by-default conflict.
DRAResourceClaimGranularStatusAuthorization: {DynamicResourceAllocation, DRAResourceClaimDeviceStatus},
DRAResourcePoolStatus: {DynamicResourceAllocation},
DRASchedulerFilterTimeout: {DynamicResourceAllocation},

View File

@@ -26,6 +26,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/authentication/user"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/kubernetes/fake"
apitesting "k8s.io/kubernetes/pkg/api/testing"
@@ -57,7 +58,7 @@ func testDeclarativeValidate(t *testing.T, apiVersion string) {
})
fakeClient := fake.NewClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
Strategy := NewStrategy(mockNSClient)
Strategy := NewStrategy(mockNSClient, nil)
opaqueDriverPath := field.NewPath("spec", "devices", "config").Index(0).Child("opaque", "driver")
@@ -717,7 +718,7 @@ func testDeclarativeValidateUpdate(t *testing.T, apiVersion string) {
})
fakeClient := fake.NewClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
Strategy := NewStrategy(mockNSClient)
Strategy := NewStrategy(mockNSClient, nil)
validClaim := mkValidResourceClaim()
// TODO: As we accumulate more and more test cases, consider breaking this
// up into smaller tests for maintainability.
@@ -835,7 +836,7 @@ func TestValidateStatusUpdateForDeclarative(t *testing.T) {
func testValidateStatusUpdateForDeclarative(t *testing.T, apiVersion string) {
fakeClient := fake.NewClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
Strategy := NewStrategy(mockNSClient)
Strategy := NewStrategy(mockNSClient, &fakeAuthorizer{true})
strategy := NewStatusStrategy(Strategy)
ctx := genericapirequest.WithRequestInfo(genericapirequest.NewDefaultContext(), &genericapirequest.RequestInfo{
@@ -844,6 +845,8 @@ func testValidateStatusUpdateForDeclarative(t *testing.T, apiVersion string) {
Resource: "resourceclaims",
Subresource: "status",
})
ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "test-user"})
poolPath := field.NewPath("status", "allocation", "devices", "results").Index(0).Child("pool")
configSourcePath := field.NewPath("status", "allocation", "devices", "config").Index(0).Child("source")
driverPath := field.NewPath("status", "allocation", "devices", "results").Index(0).Child("driver")

View File

@@ -22,10 +22,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/client-go/kubernetes/typed/core/v1"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/printers"
printersinternal "k8s.io/kubernetes/pkg/printers/internalversion"
@@ -40,11 +41,15 @@ type REST struct {
}
// NewREST returns a RESTStorage object that will work against ResourceClaims.
func NewREST(optsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface) (*REST, *StatusREST, error) {
func NewREST(optsGetter generic.RESTOptionsGetter, nsClient v1.NamespaceInterface, authorizer authorizer.Authorizer) (*REST, *StatusREST, error) {
if nsClient == nil {
return nil, nil, fmt.Errorf("namespace client is required")
}
strategy := resourceclaim.NewStrategy(nsClient)
if authorizer == nil {
return nil, nil, fmt.Errorf("authorizer is required")
}
strategy := resourceclaim.NewStrategy(nsClient, authorizer)
store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &resource.ResourceClaim{} },
NewListFunc: func() runtime.Object { return &resource.ResourceClaimList{} },

View File

@@ -17,6 +17,7 @@ limitations under the License.
package storage
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
@@ -25,6 +26,8 @@ import (
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/generic"
genericregistrytest "k8s.io/apiserver/pkg/registry/generic/testing"
@@ -36,6 +39,13 @@ import (
"k8s.io/kubernetes/pkg/registry/registrytest"
)
type fakeAuthorizer struct {
}
func (f *fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
return authorizer.DecisionAllow, "ok", nil
}
func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer) {
etcdStorage, server := registrytest.NewEtcdStorage(t, resource.GroupName)
restOptions := generic.RESTOptions{
@@ -46,7 +56,7 @@ func newStorage(t *testing.T) (*REST, *StatusREST, *etcd3testing.EtcdTestServer)
}
fakeClient := fake.NewSimpleClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
resourceClaimStorage, statusStorage, err := NewREST(restOptions, mockNSClient)
resourceClaimStorage, statusStorage, err := NewREST(restOptions, mockNSClient, &fakeAuthorizer{})
if err != nil {
t.Fatalf("unexpected error from REST storage: %v", err)
}
@@ -159,6 +169,17 @@ func TestUpdateStatus(t *testing.T) {
defer server.Terminate(t)
defer storage.Store.DestroyFunc()
ctx := genericapirequest.NewDefaultContext()
ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{Name: "system:serviceaccount:kube-system:test"})
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
IsResourceRequest: true,
Verb: "update",
APIGroup: "resource.k8s.io",
APIVersion: "v1",
Resource: "resourceclaims",
Subresource: "status",
Namespace: metav1.NamespaceDefault,
Name: "foo",
})
key, _ := storage.KeyFunc(ctx, "foo")
claimStart := validNewClaim("foo", metav1.NamespaceDefault)

View File

@@ -30,6 +30,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/apiserver/pkg/storage"
@@ -50,15 +51,17 @@ import (
type resourceclaimStrategy struct {
runtime.ObjectTyper
names.NameGenerator
nsClient v1.NamespaceInterface
nsClient v1.NamespaceInterface
authorizer authorizer.Authorizer
}
// NewStrategy is the default logic that applies when creating and updating ResourceClaim objects.
func NewStrategy(nsClient v1.NamespaceInterface) *resourceclaimStrategy {
func NewStrategy(nsClient v1.NamespaceInterface, authorizer authorizer.Authorizer) *resourceclaimStrategy {
return &resourceclaimStrategy{
legacyscheme.Scheme,
names.SimpleNameGenerator,
nsClient,
authorizer,
}
}
@@ -194,6 +197,17 @@ func (r *resourceclaimStatusStrategy) ValidateUpdate(ctx context.Context, obj, o
oldAllocationResult = oldClaim.Status.Allocation.Devices.Results
}
errs := resourceutils.AuthorizedForAdminStatus(ctx, newAllocationResult, oldAllocationResult, newClaim.Namespace, r.nsClient)
if utilfeature.DefaultFeatureGate.Enabled(features.DRAResourceClaimGranularStatusAuthorization) {
errs = append(errs, resourceutils.AuthorizedForBinding(ctx, field.NewPath("status", "allocation"), r.authorizer, newClaim.Status, oldClaim.Status)...)
// Only authorize driver device status if the claim is actually allocated since
// we drop all the device status when the claim is deallocated.
if utilfeature.DefaultFeatureGate.Enabled(features.DRAResourceClaimDeviceStatus) &&
newClaim.Status.Allocation != nil {
errs = append(errs, resourceutils.AuthorizedForDeviceStatus(ctx, field.NewPath("status", "devices"), r.authorizer, newClaim.Status, oldClaim.Status)...)
}
}
errs = append(errs, validation.ValidateResourceClaimStatusUpdate(newClaim, oldClaim)...)
return rest.ValidateDeclarativelyWithMigrationChecks(ctx, legacyscheme.Scheme, newClaim, oldClaim, errs, operation.Update)
}

View File

@@ -17,6 +17,7 @@ limitations under the License.
package resourceclaim
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
@@ -26,12 +27,15 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/client-go/kubernetes/fake"
testclient "k8s.io/client-go/testing"
featuregatetesting "k8s.io/component-base/featuregate/testing"
"k8s.io/klog/v2"
"k8s.io/kubernetes/pkg/apis/core"
"k8s.io/kubernetes/pkg/apis/resource"
"k8s.io/kubernetes/pkg/features"
"k8s.io/utils/ptr"
@@ -374,6 +378,9 @@ var fieldImmutableError = "field is immutable"
var metadataError = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters"
var deviceRequestError = "exactly one of `exactly` or `firstAvailable` is required"
var constraintError = "matchAttribute: Required value"
var bindingUpdateError = `User "test-user" cannot update resource "resourceclaims/binding" in API group "resource.k8s.io" at the cluster scope: denied`
var deviceAssociatedNodeUpdateError = `User "system:serviceaccount:kube-system:dra-driver" cannot associated-node:update resource "resourceclaims/driver" in API group "resource.k8s.io" in the namespace "default": denied`
var deviceArbitraryNodeUpdateError = `User "test-user" cannot arbitrary-node:update resource "resourceclaims/driver" in API group "resource.k8s.io" in the namespace "default": denied`
const (
req0 = "req-0"
@@ -384,6 +391,7 @@ const (
testDriver = "test-driver"
testPool = "test-pool"
testDevice = "test-device"
testUser = "test-user"
)
var (
@@ -397,7 +405,7 @@ var testCapacity = map[resource.QualifiedName]apiresource.Quantity{
func TestStrategy(t *testing.T) {
fakeClient := fake.NewSimpleClientset()
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
strategy := NewStrategy(mockNSClient, nil)
if !strategy.NamespaceScoped() {
t.Errorf("ResourceClaim must be namespace scoped")
}
@@ -629,7 +637,7 @@ func TestStrategyCreate(t *testing.T) {
features.DRAPrioritizedList: tc.prioritizedList,
features.DRAConsumableCapacity: tc.consumableCapacity,
})
strategy := NewStrategy(mockNSClient)
strategy := NewStrategy(mockNSClient, nil)
obj := tc.obj.DeepCopy()
strategy.PrepareForCreate(ctx, obj)
@@ -964,7 +972,7 @@ func TestStrategyUpdate(t *testing.T) {
features.DRAConsumableCapacity: tc.consumableCapacity,
})
strategy := NewStrategy(mockNSClient)
strategy := NewStrategy(mockNSClient, nil)
oldObj := tc.oldObj.DeepCopy()
newObj := tc.newObj.DeepCopy()
@@ -996,15 +1004,30 @@ func TestStrategyUpdate(t *testing.T) {
func TestStatusStrategyUpdate(t *testing.T) {
ctx := genericapirequest.NewDefaultContext()
ctx = genericapirequest.WithUser(ctx, &user.DefaultInfo{
Name: testUser,
Groups: []string{"system:authenticated"},
})
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
IsResourceRequest: true,
Verb: "update",
APIGroup: "resource.k8s.io",
APIVersion: "v1",
Resource: "resourceclaims",
Subresource: "status",
Namespace: metav1.NamespaceDefault,
})
testcases := map[string]struct {
oldObj *resource.ResourceClaim
newObj *resource.ResourceClaim
authz authorizer.Authorizer
ctxOverride func(context.Context) context.Context // if set, transforms the default ctx
adminAccess bool
deviceStatusFeatureGate bool
consumableCapacityFeatureGate bool
prioritizedListFeatureGate bool
bindingConditions bool
expectValidationError string
expectValidationErrors []string
expectObj *resource.ResourceClaim
verify func(*testing.T, []testclient.Action)
}{
@@ -1025,7 +1048,7 @@ func TestStatusStrategyUpdate(t *testing.T) {
obj.Name += "-2"
return obj
}(),
expectValidationError: fieldImmutableError,
expectValidationErrors: []string{fieldImmutableError},
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
@@ -1082,10 +1105,10 @@ func TestStatusStrategyUpdate(t *testing.T) {
},
},
"keep-fields-admin-access-NonAdminNamespace": {
oldObj: objInNonAdminNamespace,
newObj: objWithAdminAccessStatusInNonAdminNamespace,
adminAccess: true,
expectValidationError: adminAccessError,
oldObj: objInNonAdminNamespace,
newObj: objWithAdminAccessStatusInNonAdminNamespace,
adminAccess: true,
expectValidationErrors: []string{adminAccessError},
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 1 {
t.Errorf("expected one action but got %d", len(as))
@@ -1245,6 +1268,123 @@ func TestStatusStrategyUpdate(t *testing.T) {
}
},
},
"fail-update-fields-devices-status-without-permissions": {
oldObj: func() *resource.ResourceClaim {
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
return obj
}(),
newObj: func() *resource.ResourceClaim { // Status is added
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
return obj
}(),
adminAccess: true, // Keep emulation version at 1.36 so DRAResourceClaimGranularStatusAuthorization is active
deviceStatusFeatureGate: true,
expectValidationErrors: []string{deviceArbitraryNodeUpdateError},
expectObj: func() *resource.ResourceClaim { // Status is not updated
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
return obj
}(),
authz: &fakeAuthorizer{false},
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"fail-update-fields-devices-status-associated-node-without-permissions": {
oldObj: func() *resource.ResourceClaim {
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
obj.Status.Allocation.NodeSelector = &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{{
MatchFields: []core.NodeSelectorRequirement{{
Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"test-node"},
}},
}},
}
return obj
}(),
newObj: func() *resource.ResourceClaim {
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
obj.Status.Allocation.NodeSelector = &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{{
MatchFields: []core.NodeSelectorRequirement{{
Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"test-node"},
}},
}},
}
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
return obj
}(),
ctxOverride: func(ctx context.Context) context.Context {
return genericapirequest.WithUser(ctx, &user.DefaultInfo{
Name: "system:serviceaccount:kube-system:dra-driver",
Groups: []string{"system:authenticated"},
Extra: map[string][]string{"authentication.kubernetes.io/node-name": {"test-node"}},
})
},
adminAccess: true,
deviceStatusFeatureGate: true,
authz: &fakeAuthorizer{false},
expectValidationErrors: []string{deviceAssociatedNodeUpdateError},
expectObj: func() *resource.ResourceClaim {
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
obj.Status.Allocation.NodeSelector = &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{{
MatchFields: []core.NodeSelectorRequirement{{
Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"test-node"},
}},
}},
}
return obj
}(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"fail-drop-status-deallocated-device-without-permissions": {
oldObj: func() *resource.ResourceClaim {
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusAllocationDevicesResults(obj, testDriver, testPool, testDevice, testRequest, nil, nil)
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
return obj
}(),
newObj: func() *resource.ResourceClaim { // device is deallocated
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
addStatusDevices(obj, testDriver, testPool, testDevice, nil)
return obj
}(),
adminAccess: true, // Keep emulation version at 1.36 so DRAResourceClaimGranularStatusAuthorization is active
authz: &fakeAuthorizer{false},
deviceStatusFeatureGate: true,
expectValidationErrors: []string{bindingUpdateError},
expectObj: func() *resource.ResourceClaim { // Status is no longer there
obj := obj.DeepCopy()
addSpecDevicesRequest(obj, testRequest)
return obj
}(),
verify: func(t *testing.T, as []testclient.Action) {
if len(as) != 0 {
t.Errorf("expected no action to be taken")
}
},
},
"drop-status-deallocated-device-disable-feature-gate": {
oldObj: func() *resource.ResourceClaim {
obj := obj.DeepCopy()
@@ -1562,7 +1702,11 @@ func TestStatusStrategyUpdate(t *testing.T) {
t.Run(name, func(t *testing.T) {
fakeClient := fake.NewSimpleClientset(ns1, ns2)
mockNSClient := fakeClient.CoreV1().Namespaces()
strategy := NewStrategy(mockNSClient)
authz := tc.authz
if tc.authz == nil {
authz = &fakeAuthorizer{true}
}
strategy := NewStrategy(mockNSClient, authz)
if !tc.adminAccess {
featuregatetesting.SetFeatureGateEmulationVersionDuringTest(t, utilfeature.DefaultFeatureGate, version.MustParse("1.35"))
@@ -1580,20 +1724,27 @@ func TestStatusStrategyUpdate(t *testing.T) {
})
statusStrategy := NewStatusStrategy(strategy)
ctx := ctx
if tc.ctxOverride != nil {
ctx = tc.ctxOverride(ctx)
}
oldObj := tc.oldObj.DeepCopy()
newObj := tc.newObj.DeepCopy()
newObj.ResourceVersion = "4"
statusStrategy.PrepareForUpdate(ctx, newObj, oldObj)
if errs := statusStrategy.ValidateUpdate(ctx, newObj, oldObj); len(errs) != 0 {
if tc.expectValidationError == "" {
if len(tc.expectValidationErrors) == 0 {
t.Fatalf("unexpected error(s): %v", errs)
}
assert.Len(t, errs, 1, "exactly one error expected")
assert.ErrorContains(t, errs[0], tc.expectValidationError, "the error message should have contained the expected error message")
assert.Len(t, errs, len(tc.expectValidationErrors), "wrong number of validation errors")
for i, expectValidationError := range tc.expectValidationErrors {
assert.ErrorContains(t, errs[i], expectValidationError, "the error message should have contained the expected error message")
}
return
}
if tc.expectValidationError != "" {
if len(tc.expectValidationErrors) != 0 {
t.Fatal("expected validation error(s), got none")
}
if warnings := statusStrategy.WarningsOnUpdate(ctx, newObj, oldObj); len(warnings) != 0 {
@@ -1661,3 +1812,14 @@ func addStatusDevices(resourceClaim *resource.ResourceClaim, driver string, pool
ShareID: (*string)(shareID),
})
}
type fakeAuthorizer struct {
verdict bool
}
func (f *fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
if !f.verdict {
return authorizer.DecisionDeny, "denied", nil
}
return authorizer.DecisionAllow, "default accept", nil
}

View File

@@ -21,6 +21,7 @@ import (
resourcev1alpha3 "k8s.io/api/resource/v1alpha3"
resourcev1beta1 "k8s.io/api/resource/v1beta1"
resourcev1beta2 "k8s.io/api/resource/v1beta2"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/generic"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -42,6 +43,7 @@ import (
type RESTStorageProvider struct {
NamespaceClient v1.NamespaceInterface
Authorizer authorizer.Authorizer
}
func (p RESTStorageProvider) NewRESTStorage(apiResourceConfigSource serverstorage.APIResourceConfigSource, restOptionsGetter generic.RESTOptionsGetter) (genericapiserver.APIGroupInfo, error) {
@@ -88,7 +90,7 @@ func (p RESTStorageProvider) v1Storage(apiResourceConfigSource serverstorage.API
}
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1.SchemeGroupVersion.WithResource(resource)) {
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient, p.Authorizer)
if err != nil {
return nil, err
}
@@ -151,7 +153,7 @@ func (p RESTStorageProvider) v1beta1Storage(apiResourceConfigSource serverstorag
}
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1beta1.SchemeGroupVersion.WithResource(resource)) {
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient, p.Authorizer)
if err != nil {
return nil, err
}
@@ -199,7 +201,7 @@ func (p RESTStorageProvider) v1beta2Storage(apiResourceConfigSource serverstorag
}
if resource := "resourceclaims"; apiResourceConfigSource.ResourceEnabled(resourcev1beta2.SchemeGroupVersion.WithResource(resource)) {
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient)
resourceClaimStorage, resourceClaimStatusStorage, err := resourceclaimstore.NewREST(restOptionsGetter, nsClient, p.Authorizer)
if err != nil {
return nil, err
}

View File

@@ -20,9 +20,19 @@ import (
"context"
"fmt"
resourcev1 "k8s.io/api/resource/v1"
"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/filters"
"k8s.io/apiserver/pkg/endpoints/handlers/responsewriters"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/pkg/apis/core"
"k8s.io/kubernetes/pkg/apis/core/validation"
"k8s.io/kubernetes/pkg/apis/resource"
)
@@ -102,3 +112,221 @@ func adminRequested(deviceRequestResults []resource.DeviceRequestAllocationResul
}
return false, nil
}
// AuthorizedForBinding checks if the caller is authorized to update
// status.allocation and status.reservedFor by verifying permission on the
// synthetic resourceclaims/binding subresource.
func AuthorizedForBinding(ctx context.Context, fieldPath *field.Path, authz authorizer.Authorizer, newStatus, oldStatus resource.ResourceClaimStatus) field.ErrorList {
var allErrs field.ErrorList
if equality.Semantic.DeepEqual(newStatus.Allocation, oldStatus.Allocation) &&
equality.Semantic.DeepEqual(newStatus.ReservedFor, oldStatus.ReservedFor) {
return allErrs
}
baseAttrs, err := filters.GetAuthorizerAttributes(ctx)
if err != nil {
return append(allErrs, field.InternalError(fieldPath, fmt.Errorf("cannot build authorizer attributes: %w", err)))
}
attrs := &syntheticSubresourceAttrs{
Attributes: baseAttrs,
verb: baseAttrs.GetVerb(), // verb is unchanged but must be specified
subresource: resourcev1.SubresourceBinding, // the scheduler calls this "bind claim"
namespace: "", // cluster-wide
name: "", // all names
}
if err := checkAuthorization(ctx, authz, attrs); err != nil {
return append(allErrs, field.Forbidden(fieldPath, fmt.Sprintf(`changing status.allocation or status.reservedFor requires resource="resourceclaims/binding", verb="%s" permission: %s`, attrs.verb, err)))
}
return allErrs
}
// AuthorizedForDeviceStatus checks if the caller is authorized to update
// status.devices by performing per-driver authorization checks using the
// associated-node / arbitrary-node verb prefix pattern on the synthetic
// resourceclaims/driver subresource.
func AuthorizedForDeviceStatus(ctx context.Context, fieldPath *field.Path, a authorizer.Authorizer, newStatus, oldStatus resource.ResourceClaimStatus) field.ErrorList {
var allErrs field.ErrorList
driversToAuthz := getModifiedDrivers(newStatus, oldStatus)
if len(driversToAuthz) == 0 {
return allErrs
}
baseAttrs, err := filters.GetAuthorizerAttributes(ctx)
if err != nil {
return append(allErrs, field.InternalError(fieldPath, fmt.Errorf("cannot build authorizer attributes: %w", err)))
}
// if service account is on the same node as the claim, check associated-node verb first, fall back to arbitrary-node
// Otherwise, only try arbitrary-node
requestVerb := baseAttrs.GetVerb()
var verbs []string
if saAssociatedWithAllocatedNode(baseAttrs.GetUser(), nodeNameFromAllocation(newStatus.Allocation)) {
verbs = []string{resourcev1.VerbPrefixAssociatedNode + requestVerb, resourcev1.VerbPrefixArbitraryNode + requestVerb}
} else {
verbs = []string{resourcev1.VerbPrefixArbitraryNode + requestVerb}
}
for _, driverName := range sets.List(driversToAuthz) {
if err := checkDriverAuthorization(ctx, baseAttrs, verbs, driverName, a); err != nil {
allErrs = append(allErrs, field.Forbidden(fieldPath, fmt.Sprintf(`changing status.devices requires resource="resourceclaims/driver", verb="%s" permission: %s`, verbs, err)))
}
}
return allErrs
}
func checkDriverAuthorization(ctx context.Context, baseAttrs authorizer.Attributes, verbs []string, driverName string, a authorizer.Authorizer) error {
if len(verbs) == 0 {
return fmt.Errorf("no verbs set for driver %s", driverName) // impossible for all inputs today
}
var firstErr error
for _, verb := range verbs { // verbs are OR'd with each other
attrs := &syntheticSubresourceAttrs{
Attributes: baseAttrs,
verb: verb,
subresource: resourcev1.SubresourceDriver,
namespace: baseAttrs.GetNamespace(),
name: driverName,
}
err := checkAuthorization(ctx, a, attrs)
if err == nil {
return nil
}
if firstErr == nil {
firstErr = err
}
}
return firstErr
}
// getModifiedDrivers identifies all drivers whose status entries were added,
// removed, or changed between the old and new ResourceClaim objects.
func getModifiedDrivers(newAllocatedDeviceStatus, oldAllocatedDeviceStatus resource.ResourceClaimStatus) sets.Set[string] {
driversToAuthz := sets.Set[string]{}
oldDevices := make(map[deviceKey]resource.AllocatedDeviceStatus)
for _, d := range oldAllocatedDeviceStatus.Devices {
oldDevices[makeDeviceKey(d)] = d
}
// Check for new or modified device entries
for _, d := range newAllocatedDeviceStatus.Devices {
key := makeDeviceKey(d)
oldDevice, ok := oldDevices[key]
delete(oldDevices, key) // Remove from map to track processed devices
// If entry is new or changed, we need to authorize this driver.
if !ok || !equality.Semantic.DeepEqual(oldDevice, d) {
driversToAuthz.Insert(d.Driver)
}
}
// Check for removed device entries
for _, d := range oldDevices {
// Any remaining device in oldDevices was removed in rcNew.
driversToAuthz.Insert(d.Driver)
}
return driversToAuthz
}
type deviceKey struct {
driver string
pool string
device string
shareID string
}
func makeDeviceKey(d resource.AllocatedDeviceStatus) deviceKey {
key := deviceKey{
driver: d.Driver,
pool: d.Pool,
device: d.Device,
}
if d.ShareID != nil {
key.shareID = *d.ShareID
}
return key
}
func nodeNameFromAllocation(allocation *resource.AllocationResult) string {
if allocation == nil || allocation.NodeSelector == nil {
return ""
}
ns := allocation.NodeSelector
if len(ns.NodeSelectorTerms) != 1 {
return ""
}
term := ns.NodeSelectorTerms[0]
if len(term.MatchExpressions) != 0 || len(term.MatchFields) != 1 {
return ""
}
f := term.MatchFields[0]
if f.Key != "metadata.name" || f.Operator != core.NodeSelectorOpIn || len(f.Values) != 1 {
return ""
}
return f.Values[0]
}
func saAssociatedWithAllocatedNode(u user.Info, allocatedNodeName string) bool {
if len(allocatedNodeName) == 0 {
return false
}
// Must be a ServiceAccount
if _, _, err := serviceaccount.SplitUsername(u.GetName()); err != nil {
return false
}
// Must have exactly one node-name extra attribute
nodeNames := u.GetExtra()[serviceaccount.NodeNameKey]
if len(nodeNames) != 1 {
return false
}
nodeName := nodeNames[0]
// Must be a valid node name format
if len(validation.ValidateNodeName(nodeName, false)) != 0 {
return false
}
return nodeName == allocatedNodeName
}
func checkAuthorization(ctx context.Context, a authorizer.Authorizer, attributes authorizer.Attributes) error {
authorized, reason, err := a.Authorize(ctx, attributes)
// an authorizer like RBAC could encounter evaluation errors and still allow the request, so authorizer decision is checked before error here.
if authorized == authorizer.DecisionAllow {
return nil
}
msg := reason
switch {
case err != nil && len(reason) > 0:
msg = fmt.Sprintf("%v: %s", err, reason)
case err != nil:
msg = err.Error()
}
return responsewriters.ForbiddenStatusError(attributes, msg)
}
type syntheticSubresourceAttrs struct {
authorizer.Attributes
verb string
subresource string
namespace string
name string
}
func (a *syntheticSubresourceAttrs) GetVerb() string { return a.verb }
func (a *syntheticSubresourceAttrs) GetSubresource() string { return a.subresource }
func (a *syntheticSubresourceAttrs) GetNamespace() string { return a.namespace }
func (a *syntheticSubresourceAttrs) GetName() string { return a.name }

View File

@@ -0,0 +1,740 @@
/*
Copyright 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 resource
import (
"context"
"fmt"
"net/http"
"reflect"
"testing"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/kubernetes/pkg/apis/core"
"k8s.io/kubernetes/pkg/apis/resource"
)
// TestGetModifiedDrivers contains the unit tests for the getModifiedDrivers function.
func TestGetModifiedDrivers(t *testing.T) {
// Helper to create AllocatedDeviceStatus
devStatus := func(driver, pool, device string, network *resource.NetworkDeviceData) resource.AllocatedDeviceStatus {
return resource.AllocatedDeviceStatus{
Driver: driver,
Pool: pool,
Device: device,
NetworkData: network,
}
}
// Helper to create ResourceClaimStatus
claimStatus := func(devices ...resource.AllocatedDeviceStatus) resource.ResourceClaimStatus {
return resource.ResourceClaimStatus{
Devices: devices,
}
}
testCases := map[string]struct {
newStatus resource.ResourceClaimStatus
oldStatus resource.ResourceClaimStatus
expected sets.Set[string]
}{
"no changes": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
devStatus("driver-b", "pool-1", "dev-2", nil),
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
devStatus("driver-b", "pool-1", "dev-2", nil),
),
expected: sets.Set[string]{},
},
"add one device": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
devStatus("driver-b", "pool-1", "dev-2", nil), // New
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
),
expected: sets.New[string]("driver-b"),
},
"add device for existing driver": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
devStatus("driver-a", "pool-1", "dev-2", nil), // New
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
),
expected: sets.New[string]("driver-a"),
},
"remove one device": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
devStatus("driver-b", "pool-1", "dev-2", nil), // Removed
),
expected: sets.New[string]("driver-b"),
},
"remove device for driver that still has other devices": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
devStatus("driver-a", "pool-1", "dev-2", nil), // Removed
),
expected: sets.New[string]("driver-a"),
},
"modify one device": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}), // Modified
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
),
expected: sets.New[string]("driver-a"),
},
"modify device for driver, no change for other driver": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}), // Modified
devStatus("driver-b", "pool-1", "dev-2", nil),
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
devStatus("driver-b", "pool-1", "dev-2", nil),
),
expected: sets.New[string]("driver-a"),
},
"complex change (add, remove, modify)": {
newStatus: claimStatus(
// driver-a: dev-1 modified
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}), // Modified
// driver-b: dev-2 unchanged
devStatus("driver-b", "pool-1", "dev-2", nil),
// driver-c: dev-3 added
devStatus("driver-c", "pool-1", "dev-3", nil),
),
oldStatus: claimStatus(
// driver-a: dev-1 old state
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
// driver-b: dev-2 unchanged
devStatus("driver-b", "pool-1", "dev-2", nil),
// driver-d: dev-4 removed
devStatus("driver-d", "pool-1", "dev-4", nil),
),
expected: sets.New[string]("driver-a", "driver-c", "driver-d"),
},
"empty to empty": {
newStatus: claimStatus(),
oldStatus: claimStatus(),
expected: sets.Set[string]{},
},
"empty to one device": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
),
oldStatus: claimStatus(),
expected: sets.New[string]("driver-a"),
},
"one device to empty": {
newStatus: claimStatus(),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", nil),
),
expected: sets.New[string]("driver-a"),
},
"replace device with same key but different content": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0", IPs: []string{"192.168.7.1/24"}}),
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-1", &resource.NetworkDeviceData{InterfaceName: "eth0"}),
),
expected: sets.New[string]("driver-a"),
},
"replace device with different key for same driver": {
newStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-NEW", nil),
),
oldStatus: claimStatus(
devStatus("driver-a", "pool-1", "dev-OLD", nil),
),
expected: sets.New[string]("driver-a"),
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
result := getModifiedDrivers(tc.newStatus, tc.oldStatus)
if !reflect.DeepEqual(result, tc.expected) {
t.Errorf("Expected driver set %v, but got %v", tc.expected, result)
}
})
}
}
// fakeAuthorizer records authorization calls and returns preconfigured decisions.
// The key format is "verb/resource/subresource/name".
type fakeAuthorizer struct {
rules map[string]authorizer.Decision
err error
callCounts map[string]int
}
func (f *fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
if f.callCounts == nil {
f.callCounts = make(map[string]int)
}
key := fmt.Sprintf("%s/%s/%s/%s", a.GetVerb(), a.GetResource(), a.GetSubresource(), a.GetName())
f.callCounts[key]++
if f.err != nil {
return authorizer.DecisionDeny, "forced error", f.err
}
if decision, ok := f.rules[key]; ok {
return decision, "", nil
}
return authorizer.DecisionDeny, "no rule matched", nil
}
// withRequestContext builds a context with user info and request info set,
// simulating what GetAuthorizerAttributes expects.
func withRequestContext(ctx context.Context, u user.Info, verb string) context.Context {
ctx = genericapirequest.WithUser(ctx, u)
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
IsResourceRequest: true,
Verb: verb,
APIGroup: "resource.k8s.io",
APIVersion: "v1",
Resource: "resourceclaims",
Subresource: "status",
Namespace: "default",
Name: "test-claim",
})
// GetAuthorizerAttributes also needs an http.Request in context for audit, but
// the function doesn't fail without it — we simulate by using a dummy request.
req, _ := http.NewRequestWithContext(ctx, http.MethodPut, "/", nil)
ctx = req.Context()
return ctx
}
func singleNodeAllocation(nodeName string) *resource.AllocationResult {
return &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{
MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{nodeName}},
},
},
},
},
}
}
func TestAuthorizedForBinding(t *testing.T) {
saName := "system:serviceaccount:kube-system:scheduler"
testUser := &user.DefaultInfo{Name: saName}
fp := field.NewPath("status")
testcases := []struct {
name string
newStatus resource.ResourceClaimStatus
oldStatus resource.ResourceClaimStatus
authz *fakeAuthorizer
expectErrs int
}{
{
name: "no allocation or reservedFor change, no check needed",
newStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: "d", Pool: "p", Device: "dev"}},
},
oldStatus: resource.ResourceClaimStatus{},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
expectErrs: 0,
},
{
name: "allocation changed, authorized",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation("node-1"),
},
oldStatus: resource.ResourceClaimStatus{},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
"update/resourceclaims/binding/": authorizer.DecisionAllow,
}},
expectErrs: 0,
},
{
name: "allocation changed, not authorized",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation("node-1"),
},
oldStatus: resource.ResourceClaimStatus{},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
expectErrs: 1,
},
{
name: "reservedFor changed, authorized",
newStatus: resource.ResourceClaimStatus{
ReservedFor: []resource.ResourceClaimConsumerReference{{Resource: "pods", Name: "pod-1", UID: "uid-1"}},
},
oldStatus: resource.ResourceClaimStatus{},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
"update/resourceclaims/binding/": authorizer.DecisionAllow,
}},
expectErrs: 0,
},
{
name: "reservedFor changed, not authorized",
newStatus: resource.ResourceClaimStatus{
ReservedFor: []resource.ResourceClaimConsumerReference{{Resource: "pods", Name: "pod-1", UID: "uid-1"}},
},
oldStatus: resource.ResourceClaimStatus{},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
expectErrs: 1,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
ctx := withRequestContext(context.Background(), testUser, "update")
errs := AuthorizedForBinding(ctx, fp, tc.authz, tc.newStatus, tc.oldStatus)
if len(errs) != tc.expectErrs {
t.Errorf("expected %d errors, got %d: %v", tc.expectErrs, len(errs), errs)
}
})
}
}
func TestAuthorizedForDeviceStatus(t *testing.T) {
saName := "system:serviceaccount:kube-system:dra-driver"
nodeName := "test-node"
driverName := "test-driver"
fp := field.NewPath("status", "devices")
testcases := []struct {
name string
newStatus resource.ResourceClaimStatus
oldStatus resource.ResourceClaimStatus
user user.Info
authz *fakeAuthorizer
verb string
expectErrs int
}{
{
name: "no drivers modified",
newStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{
{Driver: driverName, Pool: "pool", Device: "device"},
},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{
{Driver: driverName, Pool: "pool", Device: "device"},
},
},
user: &user.DefaultInfo{Name: saName},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
verb: "update",
expectErrs: 0,
},
{
name: "associated-node: SA on same node, allowed by associated-node verb",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("associated-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "update",
expectErrs: 0,
},
{
name: "associated-node: SA on same node, allowed by arbitrary-node fallback",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "update",
expectErrs: 0,
},
{
name: "associated-node: SA on same node, neither verb allowed",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
verb: "update",
expectErrs: 1,
},
{
name: "SA on different node, only arbitrary-node checked",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation("other-node"),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "update",
expectErrs: 0,
},
{
name: "SA on different node, associated-node not checked, denied",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation("other-node"),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
// Only grant associated-node, which should NOT be checked since nodes differ
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("associated-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "update",
expectErrs: 1,
},
{
name: "no node association (controller), only arbitrary-node checked, allowed",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName}, // no node-name extra
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "update",
expectErrs: 0,
},
{
name: "no node association (controller), denied",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{}},
verb: "update",
expectErrs: 1,
},
{
name: "multi-node claim (no single node in selector), only arbitrary-node",
newStatus: resource.ResourceClaimStatus{
Allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-a", "node-b"}},
}},
},
},
},
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {"node-a"}}},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("arbitrary-node:update/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "update",
expectErrs: 0,
},
{
name: "patch verb propagated",
newStatus: resource.ResourceClaimStatus{
Allocation: singleNodeAllocation(nodeName),
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-new"}},
},
oldStatus: resource.ResourceClaimStatus{
Devices: []resource.AllocatedDeviceStatus{{Driver: driverName, Pool: "pool", Device: "dev-old"}},
},
user: &user.DefaultInfo{Name: saName, Extra: map[string][]string{serviceaccount.NodeNameKey: {nodeName}}},
authz: &fakeAuthorizer{rules: map[string]authorizer.Decision{
fmt.Sprintf("associated-node:patch/resourceclaims/driver/%s", driverName): authorizer.DecisionAllow,
}},
verb: "patch",
expectErrs: 0,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
ctx := withRequestContext(context.Background(), tc.user, tc.verb)
errs := AuthorizedForDeviceStatus(ctx, fp, tc.authz, tc.newStatus, tc.oldStatus)
if len(errs) != tc.expectErrs {
t.Errorf("expected %d errors, got %d: %v", tc.expectErrs, len(errs), errs)
}
})
}
}
func TestNodeNameFromAllocation(t *testing.T) {
testCases := []struct {
name string
allocation *resource.AllocationResult
expected string
}{
{
name: "nil allocation",
allocation: nil,
expected: "",
},
{
name: "nil node selector",
allocation: &resource.AllocationResult{},
expected: "",
},
{
name: "exact single-node match",
allocation: singleNodeAllocation("worker-1"),
expected: "worker-1",
},
{
name: "multiple values",
allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-a", "node-b"}},
}},
},
},
},
expected: "",
},
{
name: "match expressions instead of match fields",
allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchExpressions: []core.NodeSelectorRequirement{
{Key: "kubernetes.io/hostname", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
}},
},
},
},
expected: "",
},
{
name: "multiple terms",
allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
}},
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-2"}},
}},
},
},
},
expected: "",
},
{
name: "wrong key",
allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.namespace", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
}},
},
},
},
expected: "",
},
{
name: "wrong operator",
allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpNotIn, Values: []string{"node-1"}},
}},
},
},
},
expected: "",
},
{
name: "extra match fields",
allocation: &resource.AllocationResult{
NodeSelector: &core.NodeSelector{
NodeSelectorTerms: []core.NodeSelectorTerm{
{MatchFields: []core.NodeSelectorRequirement{
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
{Key: "metadata.name", Operator: core.NodeSelectorOpIn, Values: []string{"node-1"}},
}},
},
},
},
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := nodeNameFromAllocation(tc.allocation)
if result != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, result)
}
})
}
}
func TestSAAssociatedWithAllocatedNode(t *testing.T) {
validSA := &user.DefaultInfo{
Name: "system:serviceaccount:default:dra-driver-sa",
Extra: map[string][]string{
serviceaccount.NodeNameKey: {"worker-node-1"},
},
}
testCases := []struct {
name string
userInfo user.Info
allocatedNodeName string
expected bool
}{
{
name: "SA on matching node",
userInfo: validSA,
allocatedNodeName: "worker-node-1",
expected: true,
},
{
name: "SA on different node",
userInfo: validSA,
allocatedNodeName: "worker-node-2",
expected: false,
},
{
name: "empty allocated node name",
userInfo: validSA,
allocatedNodeName: "",
expected: false,
},
{
name: "not a service account (kubelet identity)",
userInfo: &user.DefaultInfo{
Name: "system:node:worker-node-1",
Extra: map[string][]string{
serviceaccount.NodeNameKey: {"worker-node-1"},
},
},
allocatedNodeName: "worker-node-1",
expected: false,
},
{
name: "not a service account (regular user)",
userInfo: &user.DefaultInfo{
Name: "jane-doe",
Extra: map[string][]string{
serviceaccount.NodeNameKey: {"worker-node-1"},
},
},
allocatedNodeName: "worker-node-1",
expected: false,
},
{
name: "service account missing node name extra attribute",
userInfo: &user.DefaultInfo{
Name: "system:serviceaccount:default:dra-driver-sa",
Extra: map[string][]string{},
},
allocatedNodeName: "worker-node-1",
expected: false,
},
{
name: "service account with multiple node names in extra attribute",
userInfo: &user.DefaultInfo{
Name: "system:serviceaccount:default:dra-driver-sa",
Extra: map[string][]string{
serviceaccount.NodeNameKey: {"worker-node-1", "worker-node-2"},
},
},
allocatedNodeName: "worker-node-1",
expected: false,
},
{
name: "service account with invalid node name format",
userInfo: &user.DefaultInfo{
Name: "system:serviceaccount:default:dra-driver-sa",
Extra: map[string][]string{
serviceaccount.NodeNameKey: {"invalid_node_name!"},
},
},
allocatedNodeName: "invalid_node_name!",
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := saAssociatedWithAllocatedNode(tc.userInfo, tc.allocatedNodeName)
if result != tc.expected {
t.Errorf("Expected %v, got %v", tc.expected, result)
}
})
}
}