Merge pull request #116254 from pohly/dra-node-authorizer

node authorizer: limit kubelet access to ResourceClaim objects
This commit is contained in:
Kubernetes Prow Robot 2023-07-18 13:44:04 -07:00 committed by GitHub
commit f55f2785e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 232 additions and 24 deletions

View File

@ -288,6 +288,9 @@ func (p *Plugin) admitPodStatus(nodeName string, a admission.Attributes) error {
if !labels.Equals(oldPod.Labels, newPod.Labels) {
return admission.NewForbidden(a, fmt.Errorf("node %q cannot update labels through pod status", nodeName))
}
if !resourceClaimStatusesEqual(oldPod.Status.ResourceClaimStatuses, newPod.Status.ResourceClaimStatuses) {
return admission.NewForbidden(a, fmt.Errorf("node %q cannot update resource claim statues", nodeName))
}
return nil
default:
@ -295,6 +298,29 @@ func (p *Plugin) admitPodStatus(nodeName string, a admission.Attributes) error {
}
}
func resourceClaimStatusesEqual(statusA, statusB []api.PodResourceClaimStatus) bool {
if len(statusA) != len(statusB) {
return false
}
// In most cases, status entries only get added once and not modified.
// But this cannot be guaranteed, so for the sake of correctness in all
// cases this code here has to check.
for i := range statusA {
if statusA[i].Name != statusB[i].Name {
return false
}
claimNameA := statusA[i].ResourceClaimName
claimNameB := statusB[i].ResourceClaimName
if (claimNameA == nil) != (claimNameB == nil) {
return false
}
if claimNameA != nil && *claimNameA != *claimNameB {
return false
}
}
return true
}
// admitPodEviction allows to evict a pod if it is assigned to the current node.
func (p *Plugin) admitPodEviction(nodeName string, a admission.Attributes) error {
switch a.GetOperation() {

View File

@ -22,6 +22,7 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/component-helpers/storage/ephemeral"
"k8s.io/dynamic-resource-allocation/resourceclaim"
pvutil "k8s.io/kubernetes/pkg/api/v1/persistentvolume"
podutil "k8s.io/kubernetes/pkg/api/v1/pod"
"k8s.io/kubernetes/third_party/forked/gonum/graph"
@ -117,6 +118,7 @@ const (
podVertexType
pvcVertexType
pvVertexType
resourceClaimVertexType
secretVertexType
vaVertexType
serviceAccountVertexType
@ -128,6 +130,7 @@ var vertexTypes = map[vertexType]string{
podVertexType: "pod",
pvcVertexType: "pvc",
pvVertexType: "pv",
resourceClaimVertexType: "resourceclaim",
secretVertexType: "secret",
vaVertexType: "volumeattachment",
serviceAccountVertexType: "serviceAccount",
@ -393,6 +396,20 @@ func (g *Graph) AddPod(pod *corev1.Pod) {
g.addEdgeToDestinationIndex_locked(e)
}
}
for _, podResourceClaim := range pod.Spec.ResourceClaims {
claimName, _, err := resourceclaim.Name(pod, &podResourceClaim)
// Do we have a valid claim name? If yes, add an edge that grants
// kubelet access to that claim. An error indicates that a claim
// still needs to be created, nil that intentionally no claim
// was created and never will be because it isn't needed.
if err == nil && claimName != nil {
claimVertex := g.getOrCreateVertex_locked(resourceClaimVertexType, pod.Namespace, *claimName)
e := newDestinationEdge(claimVertex, podVertex, nodeVertex)
g.graph.SetEdge(e)
g.addEdgeToDestinationIndex_locked(e)
}
}
}
func (g *Graph) DeletePod(name, namespace string) {
start := time.Now()

View File

@ -78,8 +78,9 @@ func (g *graphPopulator) updatePod(oldObj, obj interface{}) {
return
}
if oldPod, ok := oldObj.(*corev1.Pod); ok && oldPod != nil {
if (pod.Spec.NodeName == oldPod.Spec.NodeName) && (pod.UID == oldPod.UID) {
// Node and uid are unchanged, all object references in the pod spec are immutable
if (pod.Spec.NodeName == oldPod.Spec.NodeName) && (pod.UID == oldPod.UID) &&
resourceClaimStatusesEqual(oldPod.Status.ResourceClaimStatuses, pod.Status.ResourceClaimStatuses) {
// 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
}
@ -91,6 +92,29 @@ func (g *graphPopulator) updatePod(oldObj, obj interface{}) {
klog.V(5).Infof("updatePod %s/%s for node %s completed in %v", pod.Namespace, pod.Name, pod.Spec.NodeName, time.Since(startTime))
}
func resourceClaimStatusesEqual(statusA, statusB []corev1.PodResourceClaimStatus) bool {
if len(statusA) != len(statusB) {
return false
}
// In most cases, status entries only get added once and not modified.
// But this cannot be guaranteed, so for the sake of correctness in all
// cases this code here has to check.
for i := range statusA {
if statusA[i].Name != statusB[i].Name {
return false
}
claimNameA := statusA[i].ResourceClaimName
claimNameB := statusB[i].ResourceClaimName
if (claimNameA == nil) != (claimNameB == nil) {
return false
}
if claimNameA != nil && *claimNameA != *claimNameB {
return false
}
}
return true
}
func (g *graphPopulator) deletePod(obj interface{}) {
if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
obj = tombstone.Obj

View File

@ -30,6 +30,7 @@ import (
"k8s.io/component-base/featuregate"
coordapi "k8s.io/kubernetes/pkg/apis/coordination"
api "k8s.io/kubernetes/pkg/apis/core"
resourceapi "k8s.io/kubernetes/pkg/apis/resource"
storageapi "k8s.io/kubernetes/pkg/apis/storage"
"k8s.io/kubernetes/pkg/auth/nodeidentifier"
"k8s.io/kubernetes/plugin/pkg/auth/authorizer/rbac"
@ -40,7 +41,7 @@ import (
// NodeAuthorizer authorizes requests from kubelets, with the following logic:
// 1. If a request is not from a node (NodeIdentity() returns isNode=false), reject
// 2. If a specific node cannot be identified (NodeIdentity() returns nodeName=""), reject
// 3. If a request is for a secret, configmap, persistent volume or persistent volume claim, reject unless the verb is get, and the requested object is related to the requesting node:
// 3. If a request is for a secret, configmap, persistent volume, resource claim, or persistent volume claim, reject unless the verb is get, and the requested object is related to the requesting node:
// node <- configmap
// node <- pod
// node <- pod <- secret
@ -48,6 +49,7 @@ import (
// node <- pod <- pvc
// node <- pod <- pvc <- pv
// node <- pod <- pvc <- pv <- secret
// node <- pod <- ResourceClaim
// 4. For other resources, authorize all nodes uniformly using statically defined rules
type NodeAuthorizer struct {
graph *Graph
@ -72,14 +74,15 @@ func NewAuthorizer(graph *Graph, identifier nodeidentifier.NodeIdentifier, rules
}
var (
configMapResource = api.Resource("configmaps")
secretResource = api.Resource("secrets")
pvcResource = api.Resource("persistentvolumeclaims")
pvResource = api.Resource("persistentvolumes")
vaResource = storageapi.Resource("volumeattachments")
svcAcctResource = api.Resource("serviceaccounts")
leaseResource = coordapi.Resource("leases")
csiNodeResource = storageapi.Resource("csinodes")
configMapResource = api.Resource("configmaps")
secretResource = api.Resource("secrets")
pvcResource = api.Resource("persistentvolumeclaims")
pvResource = api.Resource("persistentvolumes")
resourceClaimResource = resourceapi.Resource("resourceclaims")
vaResource = storageapi.Resource("volumeattachments")
svcAcctResource = api.Resource("serviceaccounts")
leaseResource = coordapi.Resource("leases")
csiNodeResource = storageapi.Resource("csinodes")
)
func (r *NodeAuthorizer) RulesFor(user user.Info, namespace string) ([]authorizer.ResourceRuleInfo, []authorizer.NonResourceRuleInfo, bool, error) {
@ -117,6 +120,8 @@ func (r *NodeAuthorizer) Authorize(ctx context.Context, attrs authorizer.Attribu
return r.authorizeGet(nodeName, pvcVertexType, attrs)
case pvResource:
return r.authorizeGet(nodeName, pvVertexType, attrs)
case resourceClaimResource:
return r.authorizeGet(nodeName, resourceClaimVertexType, attrs)
case vaResource:
return r.authorizeGet(nodeName, vaVertexType, attrs)
case svcAcctResource:

View File

@ -42,16 +42,19 @@ func TestAuthorizer(t *testing.T) {
g := NewGraph()
opts := &sampleDataOpts{
nodes: 2,
namespaces: 2,
podsPerNode: 2,
attachmentsPerNode: 1,
sharedConfigMapsPerPod: 0,
uniqueConfigMapsPerPod: 1,
sharedSecretsPerPod: 1,
uniqueSecretsPerPod: 1,
sharedPVCsPerPod: 0,
uniquePVCsPerPod: 1,
nodes: 2,
namespaces: 2,
podsPerNode: 2,
attachmentsPerNode: 1,
sharedConfigMapsPerPod: 0,
uniqueConfigMapsPerPod: 1,
sharedSecretsPerPod: 1,
uniqueSecretsPerPod: 1,
sharedPVCsPerPod: 0,
uniquePVCsPerPod: 1,
uniqueResourceClaimsPerPod: 1,
uniqueResourceClaimTemplatesPerPod: 1,
uniqueResourceClaimTemplatesWithClaimPerPod: 1,
}
nodes, pods, pvs, attachments := generate(opts)
populate(g, nodes, pods, pvs, attachments)
@ -117,6 +120,16 @@ func TestAuthorizer(t *testing.T) {
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "persistentvolumeclaims", Name: "pvc0-pod0-node0", Namespace: "ns0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed resource claim",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Name: "claim0-pod0-node0-ns0", Namespace: "ns0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed resource claim with template",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Name: "generated-claim-pod0-node0-ns0-0", Namespace: "ns0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed pv",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "persistentvolumes", Name: "pv0-pod0-node0-ns0", Namespace: ""},
@ -142,6 +155,16 @@ func TestAuthorizer(t *testing.T) {
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "persistentvolumeclaims", Name: "pvc0-pod0-node1", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed resource claim, other node",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Name: "claim0-pod0-node1-ns0", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed resource claim with template",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "resourceclaims", APIGroup: "resource.k8s.io", Name: "pod0-node1-claimtemplate0", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed pv",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "persistentvolumes", Name: "pv0-pod0-node1-ns0", Namespace: ""},
@ -468,9 +491,12 @@ type sampleDataOpts struct {
sharedSecretsPerPod int
sharedPVCsPerPod int
uniqueSecretsPerPod int
uniqueConfigMapsPerPod int
uniquePVCsPerPod int
uniqueSecretsPerPod int
uniqueConfigMapsPerPod int
uniquePVCsPerPod int
uniqueResourceClaimsPerPod int
uniqueResourceClaimTemplatesPerPod int
uniqueResourceClaimTemplatesWithClaimPerPod int
}
func BenchmarkPopulationAllocation(b *testing.B) {
@ -845,6 +871,40 @@ func generatePod(name, namespace, nodeName, svcAccountName string, opts *sampleD
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: pv.Spec.ClaimRef.Name},
}})
}
for i := 0; i < opts.uniqueResourceClaimsPerPod; i++ {
claimName := fmt.Sprintf("claim%d-%s-%s", i, pod.Name, pod.Namespace)
pod.Spec.ResourceClaims = append(pod.Spec.ResourceClaims, corev1.PodResourceClaim{
Name: fmt.Sprintf("claim%d", i),
Source: corev1.ClaimSource{
ResourceClaimName: &claimName,
},
})
}
for i := 0; i < opts.uniqueResourceClaimTemplatesPerPod; i++ {
claimTemplateName := fmt.Sprintf("claimtemplate%d-%s-%s", i, pod.Name, pod.Namespace)
podClaimName := fmt.Sprintf("claimtemplate%d", i)
pod.Spec.ResourceClaims = append(pod.Spec.ResourceClaims, corev1.PodResourceClaim{
Name: podClaimName,
Source: corev1.ClaimSource{
ResourceClaimTemplateName: &claimTemplateName,
},
})
}
for i := 0; i < opts.uniqueResourceClaimTemplatesWithClaimPerPod; i++ {
claimTemplateName := fmt.Sprintf("claimtemplate%d-%s-%s", i, pod.Name, pod.Namespace)
podClaimName := fmt.Sprintf("claimtemplate-with-claim%d", i)
claimName := fmt.Sprintf("generated-claim-%s-%s-%d", pod.Name, pod.Namespace, i)
pod.Spec.ResourceClaims = append(pod.Spec.ResourceClaims, corev1.PodResourceClaim{
Name: podClaimName,
Source: corev1.ClaimSource{
ResourceClaimTemplateName: &claimTemplateName,
},
})
pod.Status.ResourceClaimStatuses = append(pod.Status.ResourceClaimStatuses, corev1.PodResourceClaimStatus{
Name: podClaimName,
ResourceClaimName: &claimName,
})
}
// Choose shared pvcs randomly from shared pvcs in a namespace.
subset = randomSubset(opts.sharedPVCsPerPod, opts.sharedPVCsPerNamespace)
for _, i := range subset {

View File

@ -27,14 +27,18 @@ import (
coordination "k8s.io/api/coordination/v1"
corev1 "k8s.io/api/core/v1"
policy "k8s.io/api/policy/v1"
"k8s.io/api/resource/v1alpha2"
storagev1 "k8s.io/api/storage/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
utilfeature "k8s.io/apiserver/pkg/util/feature"
clientset "k8s.io/client-go/kubernetes"
featuregatetesting "k8s.io/component-base/featuregate/testing"
kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
"k8s.io/kubernetes/pkg/features"
"k8s.io/kubernetes/test/integration/framework"
"k8s.io/utils/pointer"
)
@ -61,7 +65,10 @@ func TestNodeAuthorizer(t *testing.T) {
}, "\n"))
tokenFile.Close()
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DynamicResourceAllocation, true)()
server := kubeapiservertesting.StartTestServerOrDie(t, nil, []string{
"--runtime-config=api/all=true",
"--authorization-mode", "Node,RBAC",
"--token-auth-file", tokenFile.Name(),
"--enable-admission-plugins", "NodeRestriction",
@ -100,6 +107,13 @@ func TestNodeAuthorizer(t *testing.T) {
if _, err := superuserClient.CoreV1().ConfigMaps("ns").Create(context.TODO(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "myconfigmap"}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
if _, err := superuserClient.ResourceV1alpha2().ResourceClaims("ns").Create(context.TODO(), &v1alpha2.ResourceClaim{ObjectMeta: metav1.ObjectMeta{Name: "mynamedresourceclaim"}, Spec: v1alpha2.ResourceClaimSpec{ResourceClassName: "example.com"}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
if _, err := superuserClient.ResourceV1alpha2().ResourceClaims("ns").Create(context.TODO(), &v1alpha2.ResourceClaim{ObjectMeta: metav1.ObjectMeta{Name: "mytemplatizedresourceclaim"}, Spec: v1alpha2.ResourceClaimSpec{ResourceClassName: "example.com"}}, metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
pvName := "mypv"
if _, err := superuserClientExternal.StorageV1().VolumeAttachments().Create(context.TODO(), &storagev1.VolumeAttachment{
ObjectMeta: metav1.ObjectMeta{Name: "myattachment"},
@ -169,6 +183,34 @@ func TestNodeAuthorizer(t *testing.T) {
return err
}
}
getResourceClaim := func(client clientset.Interface) func() error {
return func() error {
_, err := client.ResourceV1alpha2().ResourceClaims("ns").Get(context.TODO(), "mynamedresourceclaim", metav1.GetOptions{})
return err
}
}
getResourceClaimTemplate := func(client clientset.Interface) func() error {
return func() error {
_, err := client.ResourceV1alpha2().ResourceClaims("ns").Get(context.TODO(), "mytemplatizedresourceclaim", metav1.GetOptions{})
return err
}
}
addResourceClaimTemplateReference := func(client clientset.Interface) func() error {
return func() error {
_, err := client.CoreV1().Pods("ns").Patch(context.TODO(), "node2normalpod", types.MergePatchType,
[]byte(`{"status":{"resourceClaimStatuses":[{"name":"templateclaim","resourceClaimName":"mytemplatizedresourceclaim"}]}}`),
metav1.PatchOptions{}, "status")
return err
}
}
removeResourceClaimReference := func(client clientset.Interface) func() error {
return func() error {
_, err := client.CoreV1().Pods("ns").Patch(context.TODO(), "node2normalpod", types.MergePatchType,
[]byte(`{"status":{"resourceClaimStatuses":null}}`),
metav1.PatchOptions{}, "status")
return err
}
}
createNode2NormalPod := func(client clientset.Interface) func() error {
return func() error {
@ -182,6 +224,10 @@ func TestNodeAuthorizer(t *testing.T) {
{Name: "cm", VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{LocalObjectReference: corev1.LocalObjectReference{Name: "myconfigmap"}}}},
{Name: "pvc", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: "mypvc"}}},
},
ResourceClaims: []corev1.PodResourceClaim{
{Name: "namedclaim", Source: corev1.ClaimSource{ResourceClaimName: pointer.String("mynamedresourceclaim")}},
{Name: "templateclaim", Source: corev1.ClaimSource{ResourceClaimTemplateName: pointer.String("myresourceclaimtemplate")}},
},
},
}, metav1.CreateOptions{})
return err
@ -428,6 +474,8 @@ func TestNodeAuthorizer(t *testing.T) {
expectForbidden(t, getConfigMap(nodeanonClient))
expectForbidden(t, getPVC(nodeanonClient))
expectForbidden(t, getPV(nodeanonClient))
expectForbidden(t, getResourceClaim(nodeanonClient))
expectForbidden(t, getResourceClaimTemplate(nodeanonClient))
expectForbidden(t, createNode2NormalPod(nodeanonClient))
expectForbidden(t, deleteNode2NormalPod(nodeanonClient))
expectForbidden(t, createNode2MirrorPodEviction(nodeanonClient))
@ -440,6 +488,8 @@ func TestNodeAuthorizer(t *testing.T) {
expectForbidden(t, getConfigMap(node1Client))
expectForbidden(t, getPVC(node1Client))
expectForbidden(t, getPV(node1Client))
expectForbidden(t, getResourceClaim(node1Client))
expectForbidden(t, getResourceClaimTemplate(node1Client))
expectForbidden(t, createNode2NormalPod(nodeanonClient))
expectNotFound(t, createNode2MirrorPodEviction(node1Client))
expectForbidden(t, createNode2(node1Client))
@ -452,6 +502,8 @@ func TestNodeAuthorizer(t *testing.T) {
expectForbidden(t, getConfigMap(node2Client))
expectForbidden(t, getPVC(node2Client))
expectForbidden(t, getPV(node2Client))
expectForbidden(t, getResourceClaim(node2Client))
expectForbidden(t, getResourceClaimTemplate(node2Client))
expectForbidden(t, createNode2NormalPod(nodeanonClient))
// mirror pod and self node lifecycle is allowed
@ -479,6 +531,8 @@ func TestNodeAuthorizer(t *testing.T) {
expectForbidden(t, getConfigMap(nodeanonClient))
expectForbidden(t, getPVC(nodeanonClient))
expectForbidden(t, getPV(nodeanonClient))
expectForbidden(t, getResourceClaim(nodeanonClient))
expectForbidden(t, getResourceClaimTemplate(nodeanonClient))
expectForbidden(t, createNode2NormalPod(nodeanonClient))
expectForbidden(t, updateNode2NormalPodStatus(nodeanonClient))
expectForbidden(t, deleteNode2NormalPod(nodeanonClient))
@ -492,6 +546,8 @@ func TestNodeAuthorizer(t *testing.T) {
expectForbidden(t, getConfigMap(node1Client))
expectForbidden(t, getPVC(node1Client))
expectForbidden(t, getPV(node1Client))
expectForbidden(t, getResourceClaim(node1Client))
expectForbidden(t, getResourceClaimTemplate(node1Client))
expectForbidden(t, createNode2NormalPod(node1Client))
expectForbidden(t, updateNode2NormalPodStatus(node1Client))
expectForbidden(t, deleteNode2NormalPod(node1Client))
@ -507,6 +563,26 @@ func TestNodeAuthorizer(t *testing.T) {
expectAllowed(t, getPVC(node2Client))
expectAllowed(t, getPV(node2Client))
// node2 can only get direct claim references
expectAllowed(t, getResourceClaim(node2Client))
expectForbidden(t, getResourceClaimTemplate(node2Client))
// node cannot add a claim reference
expectForbidden(t, addResourceClaimTemplateReference(node2Client))
// superuser can add a claim reference
expectAllowed(t, addResourceClaimTemplateReference(superuserClient))
// node can get direct and template claim references
expectAllowed(t, getResourceClaim(node2Client))
expectAllowed(t, getResourceClaimTemplate(node2Client))
// node cannot remove a claim reference
expectForbidden(t, removeResourceClaimReference(node2Client))
// superuser can remove a claim reference
expectAllowed(t, removeResourceClaimReference(superuserClient))
// node2 can only get direct claim references
expectAllowed(t, getResourceClaim(node2Client))
expectForbidden(t, getResourceClaimTemplate(node2Client))
expectForbidden(t, createNode2NormalPod(node2Client))
expectAllowed(t, updateNode2NormalPodStatus(node2Client))
expectAllowed(t, deleteNode2NormalPod(node2Client))