node authorizer: lock down access for NodeResourceSlice

The kubelet running on one node should not be allowed to access
NodeResourceSlice objects belonging to some other node, as defined by the
NodeResourceSlice.NodeName field.
This commit is contained in:
Patrick Ohly
2024-01-24 17:51:40 +01:00
parent 39bbcedbca
commit 2e34e187c9
5 changed files with 212 additions and 16 deletions

View File

@@ -114,6 +114,7 @@ type vertexType byte
const (
configMapVertexType vertexType = iota
sliceVertexType
nodeVertexType
podVertexType
pvcVertexType
@@ -126,6 +127,7 @@ const (
var vertexTypes = map[vertexType]string{
configMapVertexType: "configmap",
sliceVertexType: "noderesourceslice",
nodeVertexType: "node",
podVertexType: "pod",
pvcVertexType: "pvc",
@@ -492,3 +494,34 @@ func (g *Graph) DeleteVolumeAttachment(name string) {
defer g.lock.Unlock()
g.deleteVertex_locked(vaVertexType, "", name)
}
// AddNodeResourceSlice sets up edges for the following relationships:
//
// node resource slice -> node
func (g *Graph) AddNodeResourceSlice(sliceName, nodeName string) {
start := time.Now()
defer func() {
graphActionsDuration.WithLabelValues("AddNodeResourceSlice").Observe(time.Since(start).Seconds())
}()
g.lock.Lock()
defer g.lock.Unlock()
// clear existing edges
g.deleteVertex_locked(sliceVertexType, "", sliceName)
// if we have a node, establish new edges
if len(nodeName) > 0 {
sliceVertex := g.getOrCreateVertex_locked(sliceVertexType, "", sliceName)
nodeVertex := g.getOrCreateVertex_locked(nodeVertexType, "", nodeName)
g.graph.SetEdge(newDestinationEdge(sliceVertex, nodeVertex, nodeVertex))
}
}
func (g *Graph) DeleteNodeResourceSlice(sliceName string) {
start := time.Now()
defer func() {
graphActionsDuration.WithLabelValues("DeleteNodeResourceSlice").Observe(time.Since(start).Seconds())
}()
g.lock.Lock()
defer g.lock.Unlock()
g.deleteVertex_locked(sliceVertexType, "", sliceName)
}

View File

@@ -22,9 +22,11 @@ import (
"k8s.io/klog/v2"
corev1 "k8s.io/api/core/v1"
resourcev1alpha2 "k8s.io/api/resource/v1alpha2"
storagev1 "k8s.io/api/storage/v1"
"k8s.io/apimachinery/pkg/util/wait"
corev1informers "k8s.io/client-go/informers/core/v1"
resourcev1alpha2informers "k8s.io/client-go/informers/resource/v1alpha2"
storageinformers "k8s.io/client-go/informers/storage/v1"
"k8s.io/client-go/tools/cache"
)
@@ -39,6 +41,7 @@ func AddGraphEventHandlers(
pods corev1informers.PodInformer,
pvs corev1informers.PersistentVolumeInformer,
attachments storageinformers.VolumeAttachmentInformer,
slices resourcev1alpha2informers.NodeResourceSliceInformer,
) {
g := &graphPopulator{
graph: graph,
@@ -62,8 +65,20 @@ func AddGraphEventHandlers(
DeleteFunc: g.deleteVolumeAttachment,
})
go cache.WaitForNamedCacheSync("node_authorizer", wait.NeverStop,
podHandler.HasSynced, pvsHandler.HasSynced, attachHandler.HasSynced)
synced := []cache.InformerSynced{
podHandler.HasSynced, pvsHandler.HasSynced, attachHandler.HasSynced,
}
if slices != nil {
sliceHandler, _ := slices.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: g.addNodeResourceSlice,
UpdateFunc: nil, // Not needed, NodeName is immutable.
DeleteFunc: g.deleteNodeResourceSlice,
})
synced = append(synced, sliceHandler.HasSynced)
}
go cache.WaitForNamedCacheSync("node_authorizer", wait.NeverStop, synced...)
}
func (g *graphPopulator) addPod(obj interface{}) {
@@ -184,3 +199,24 @@ func (g *graphPopulator) deleteVolumeAttachment(obj interface{}) {
}
g.graph.DeleteVolumeAttachment(attachment.Name)
}
func (g *graphPopulator) addNodeResourceSlice(obj interface{}) {
slice, ok := obj.(*resourcev1alpha2.NodeResourceSlice)
if !ok {
klog.Infof("unexpected type %T", obj)
return
}
g.graph.AddNodeResourceSlice(slice.Name, slice.NodeName)
}
func (g *graphPopulator) deleteNodeResourceSlice(obj interface{}) {
if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
obj = tombstone.Obj
}
slice, ok := obj.(*resourcev1alpha2.NodeResourceSlice)
if !ok {
klog.Infof("unexpected type %T", obj)
return
}
g.graph.DeleteNodeResourceSlice(slice.Name)
}

View File

@@ -50,7 +50,12 @@ import (
// node <- pod <- pvc <- pv
// node <- pod <- pvc <- pv <- secret
// node <- pod <- ResourceClaim
// 4. For other resources, authorize all nodes uniformly using statically defined rules
// 4. If a request is for a noderesourceslice, then authorize access if there is an
// edge from the existing slice object to the node, which is the case if the
// existing object has the node in its NodeName field. For create, the access gets
// granted because the noderestriction admission plugin checks that the NodeName
// is set to the node.
// 5. For other resources, authorize all nodes uniformly using statically defined rules
type NodeAuthorizer struct {
graph *Graph
identifier nodeidentifier.NodeIdentifier
@@ -76,6 +81,7 @@ func NewAuthorizer(graph *Graph, identifier nodeidentifier.NodeIdentifier, rules
var (
configMapResource = api.Resource("configmaps")
secretResource = api.Resource("secrets")
nodeResourceSlice = resourceapi.Resource("noderesourceslices")
pvcResource = api.Resource("persistentvolumeclaims")
pvResource = api.Resource("persistentvolumes")
resourceClaimResource = resourceapi.Resource("resourceclaims")
@@ -130,6 +136,8 @@ func (r *NodeAuthorizer) Authorize(ctx context.Context, attrs authorizer.Attribu
return r.authorizeLease(nodeName, attrs)
case csiNodeResource:
return r.authorizeCSINode(nodeName, attrs)
case nodeResourceSlice:
return r.authorizeNodeResourceSlice(nodeName, attrs)
}
}
@@ -294,6 +302,39 @@ func (r *NodeAuthorizer) authorizeCSINode(nodeName string, attrs authorizer.Attr
return authorizer.DecisionAllow, "", nil
}
// authorizeNodeResourceSlice authorizes node requests to NodeResourceSlice resource.k8s.io/noderesourceslices
func (r *NodeAuthorizer) authorizeNodeResourceSlice(nodeName string, attrs authorizer.Attributes) (authorizer.Decision, string, error) {
if len(attrs.GetSubresource()) > 0 {
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "cannot authorize NodeResourceSlice subresources", nil
}
// allowed verbs: get, create, update, patch, delete
verb := attrs.GetVerb()
switch verb {
case "get", "create", "update", "patch", "delete":
// Okay, but check individual object permission below.
case "watch", "list":
// Okay. The kubelet is trusted to use a filter for its own objects.
return authorizer.DecisionAllow, "", nil
default:
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "can only get, create, update, patch, or delete a NodeResourceSlice", nil
}
// The request must come from a node with the same name as the NodeResourceSlice.NodeName field.
//
// For create, the noderestriction admission plugin is performing this check.
// Here we don't have access to the content of the new object.
if verb == "create" {
return authorizer.DecisionAllow, "", nil
}
// For any other verb, checking the existing object must have established that access
// is allowed by recording a graph edge.
return r.authorize(nodeName, sliceVertexType, attrs)
}
// hasPathFrom returns true if there is a directed path from the specified type/namespace/name to the specified Node
func (r *NodeAuthorizer) hasPathFrom(nodeName string, startingType vertexType, startingNamespace, startingName string) (bool, error) {
r.graph.lock.RLock()

View File

@@ -28,6 +28,7 @@ import (
"time"
corev1 "k8s.io/api/core/v1"
resourcev1alpha2 "k8s.io/api/resource/v1alpha2"
storagev1 "k8s.io/api/storage/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/authentication/user"
@@ -55,9 +56,10 @@ func TestAuthorizer(t *testing.T) {
uniqueResourceClaimsPerPod: 1,
uniqueResourceClaimTemplatesPerPod: 1,
uniqueResourceClaimTemplatesWithClaimPerPod: 1,
nodeResourceCapacitiesPerNode: 2,
}
nodes, pods, pvs, attachments := generate(opts)
populate(g, nodes, pods, pvs, attachments)
nodes, pods, pvs, attachments, slices := generate(opts)
populate(g, nodes, pods, pvs, attachments, slices)
identifier := nodeidentifier.NewDefaultNodeIdentifier()
authz := NewAuthorizer(g, identifier, bootstrappolicy.NodeRules())
@@ -336,6 +338,67 @@ func TestAuthorizer(t *testing.T) {
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "delete", Resource: "csinodes", APIGroup: "storage.k8s.io", Name: "node0"},
expect: authorizer.DecisionAllow,
},
// NodeResourceSlice
{
name: "disallowed NodeResourceSlice with subresource",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "noderesourceslices", Subresource: "status", APIGroup: "resource.k8s.io", Name: "slice0-node0"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed get another node's NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node1"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed update another node's NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "update", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node1"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed patch another node's NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "patch", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node1"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "disallowed delete another node's NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "delete", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node1"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "allowed list NodeResourceSlices",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "noderesourceslices", APIGroup: "resource.k8s.io"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed watch NodeResourceSlices",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "noderesourceslices", APIGroup: "resource.k8s.io"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed get NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed create NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "create", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed update NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "update", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed patch NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "patch", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node0"},
expect: authorizer.DecisionAllow,
},
{
name: "allowed delete NodeResourceSlice",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "delete", Resource: "noderesourceslices", APIGroup: "resource.k8s.io", Name: "slice0-node0"},
expect: authorizer.DecisionAllow,
},
}
for _, tc := range tests {
@@ -497,6 +560,8 @@ type sampleDataOpts struct {
uniqueResourceClaimsPerPod int
uniqueResourceClaimTemplatesPerPod int
uniqueResourceClaimTemplatesWithClaimPerPod int
nodeResourceCapacitiesPerNode int
}
func BenchmarkPopulationAllocation(b *testing.B) {
@@ -513,12 +578,12 @@ func BenchmarkPopulationAllocation(b *testing.B) {
uniquePVCsPerPod: 1,
}
nodes, pods, pvs, attachments := generate(opts)
nodes, pods, pvs, attachments, slices := generate(opts)
b.ResetTimer()
for i := 0; i < b.N; i++ {
g := NewGraph()
populate(g, nodes, pods, pvs, attachments)
populate(g, nodes, pods, pvs, attachments, slices)
}
}
@@ -544,14 +609,14 @@ func BenchmarkPopulationRetention(b *testing.B) {
uniquePVCsPerPod: 1,
}
nodes, pods, pvs, attachments := generate(opts)
nodes, pods, pvs, attachments, slices := generate(opts)
// Garbage collect before the first iteration
runtime.GC()
b.ResetTimer()
for i := 0; i < b.N; i++ {
g := NewGraph()
populate(g, nodes, pods, pvs, attachments)
populate(g, nodes, pods, pvs, attachments, slices)
if i == 0 {
f, _ := os.Create("BenchmarkPopulationRetention.profile")
@@ -582,9 +647,9 @@ func BenchmarkWriteIndexMaintenance(b *testing.B) {
sharedPVCsPerPod: 0,
uniquePVCsPerPod: 1,
}
nodes, pods, pvs, attachments := generate(opts)
nodes, pods, pvs, attachments, slices := generate(opts)
g := NewGraph()
populate(g, nodes, pods, pvs, attachments)
populate(g, nodes, pods, pvs, attachments, slices)
// Garbage collect before the first iteration
runtime.GC()
b.ResetTimer()
@@ -616,8 +681,8 @@ func BenchmarkAuthorization(b *testing.B) {
sharedPVCsPerPod: 0,
uniquePVCsPerPod: 1,
}
nodes, pods, pvs, attachments := generate(opts)
populate(g, nodes, pods, pvs, attachments)
nodes, pods, pvs, attachments, slices := generate(opts)
populate(g, nodes, pods, pvs, attachments, slices)
identifier := nodeidentifier.NewDefaultNodeIdentifier()
authz := NewAuthorizer(g, identifier, bootstrappolicy.NodeRules())
@@ -766,7 +831,7 @@ func BenchmarkAuthorization(b *testing.B) {
}
}
func populate(graph *Graph, nodes []*corev1.Node, pods []*corev1.Pod, pvs []*corev1.PersistentVolume, attachments []*storagev1.VolumeAttachment) {
func populate(graph *Graph, nodes []*corev1.Node, pods []*corev1.Pod, pvs []*corev1.PersistentVolume, attachments []*storagev1.VolumeAttachment, slices []*resourcev1alpha2.NodeResourceSlice) {
p := &graphPopulator{}
p.graph = graph
for _, pod := range pods {
@@ -778,6 +843,9 @@ func populate(graph *Graph, nodes []*corev1.Node, pods []*corev1.Pod, pvs []*cor
for _, attachment := range attachments {
p.addVolumeAttachment(attachment)
}
for _, slice := range slices {
p.addNodeResourceSlice(slice)
}
}
func randomSubset(a, b int) []int {
@@ -791,11 +859,12 @@ func randomSubset(a, b int) []int {
// the secret/configmap/pvc/node references in the pod and pv objects are named to indicate the connections between the objects.
// for example, secret0-pod0-node0 is a secret referenced by pod0 which is bound to node0.
// when populated into the graph, the node authorizer should allow node0 to access that secret, but not node1.
func generate(opts *sampleDataOpts) ([]*corev1.Node, []*corev1.Pod, []*corev1.PersistentVolume, []*storagev1.VolumeAttachment) {
func generate(opts *sampleDataOpts) ([]*corev1.Node, []*corev1.Pod, []*corev1.PersistentVolume, []*storagev1.VolumeAttachment, []*resourcev1alpha2.NodeResourceSlice) {
nodes := make([]*corev1.Node, 0, opts.nodes)
pods := make([]*corev1.Pod, 0, opts.nodes*opts.podsPerNode)
pvs := make([]*corev1.PersistentVolume, 0, (opts.nodes*opts.podsPerNode*opts.uniquePVCsPerPod)+(opts.sharedPVCsPerPod*opts.namespaces))
attachments := make([]*storagev1.VolumeAttachment, 0, opts.nodes*opts.attachmentsPerNode)
slices := make([]*resourcev1alpha2.NodeResourceSlice, 0, opts.nodes*opts.nodeResourceCapacitiesPerNode)
rand.Seed(12345)
@@ -821,8 +890,17 @@ func generate(opts *sampleDataOpts) ([]*corev1.Node, []*corev1.Pod, []*corev1.Pe
ObjectMeta: metav1.ObjectMeta{Name: nodeName},
Spec: corev1.NodeSpec{},
})
for p := 0; p <= opts.nodeResourceCapacitiesPerNode; p++ {
name := fmt.Sprintf("slice%d-%s", p, nodeName)
slice := &resourcev1alpha2.NodeResourceSlice{
ObjectMeta: metav1.ObjectMeta{Name: name},
NodeName: nodeName,
}
slices = append(slices, slice)
}
}
return nodes, pods, pvs, attachments
return nodes, pods, pvs, attachments, slices
}
func generatePod(name, namespace, nodeName, svcAccountName string, opts *sampleDataOpts) (*corev1.Pod, []*corev1.PersistentVolume) {