From 76463e21d4dec90b4d49975b182a13e1fdb6b20a Mon Sep 17 00:00:00 2001 From: James Munnelly Date: Tue, 19 Sep 2023 15:23:28 +0100 Subject: [PATCH] KEP-4193: bound service account token improvements --- pkg/controller/serviceaccount/tokengetter.go | 19 +- pkg/features/kube_features.go | 38 +++ pkg/kubeapiserver/options/authentication.go | 7 + pkg/registry/core/rest/storage_core.go | 7 +- .../core/rest/storage_core_generic.go | 4 +- .../core/serviceaccount/storage/storage.go | 3 +- .../serviceaccount/storage/storage_test.go | 2 +- .../core/serviceaccount/storage/token.go | 60 ++++- pkg/serviceaccount/claims.go | 118 +++++++-- pkg/serviceaccount/claims_test.go | 237 +++++++++++++++-- pkg/serviceaccount/jwt.go | 1 + pkg/serviceaccount/jwt_test.go | 8 + .../pkg/authentication/serviceaccount/util.go | 45 +++- .../serviceaccount/util_test.go | 58 ++++ .../kubectl/pkg/cmd/create/create_token.go | 19 +- .../pkg/cmd/create/create_token_test.go | 37 +++ test/integration/auth/svcaccttoken_test.go | 248 +++++++++++++++++- 17 files changed, 831 insertions(+), 80 deletions(-) diff --git a/pkg/controller/serviceaccount/tokengetter.go b/pkg/controller/serviceaccount/tokengetter.go index f2c3c307a89..98afde0b727 100644 --- a/pkg/controller/serviceaccount/tokengetter.go +++ b/pkg/controller/serviceaccount/tokengetter.go @@ -19,6 +19,7 @@ package serviceaccount import ( "context" "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientset "k8s.io/client-go/kubernetes" v1listers "k8s.io/client-go/listers/core/v1" @@ -31,14 +32,15 @@ type clientGetter struct { secretLister v1listers.SecretLister serviceAccountLister v1listers.ServiceAccountLister podLister v1listers.PodLister + nodeLister v1listers.NodeLister } // NewGetterFromClient returns a ServiceAccountTokenGetter that -// uses the specified client to retrieve service accounts and secrets. +// uses the specified client to retrieve service accounts, pods, secrets and nodes. // The client should NOT authenticate using a service account token // the returned getter will be used to retrieve, or recursion will result. -func NewGetterFromClient(c clientset.Interface, secretLister v1listers.SecretLister, serviceAccountLister v1listers.ServiceAccountLister, podLister v1listers.PodLister) serviceaccount.ServiceAccountTokenGetter { - return clientGetter{c, secretLister, serviceAccountLister, podLister} +func NewGetterFromClient(c clientset.Interface, secretLister v1listers.SecretLister, serviceAccountLister v1listers.ServiceAccountLister, podLister v1listers.PodLister, nodeLister v1listers.NodeLister) serviceaccount.ServiceAccountTokenGetter { + return clientGetter{c, secretLister, serviceAccountLister, podLister, nodeLister} } func (c clientGetter) GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error) { @@ -61,3 +63,14 @@ func (c clientGetter) GetSecret(namespace, name string) (*v1.Secret, error) { } return c.client.CoreV1().Secrets(namespace).Get(context.TODO(), name, metav1.GetOptions{}) } + +func (c clientGetter) GetNode(name string) (*v1.Node, error) { + // handle the case where the node lister isn't set due to feature being disabled + if c.nodeLister == nil { + return nil, apierrors.NewNotFound(v1.Resource("nodes"), name) + } + if node, err := c.nodeLister.Get(name); err == nil { + return node, nil + } + return c.client.CoreV1().Nodes().Get(context.TODO(), name, metav1.GetOptions{}) +} diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index c123e3803cb..77722130ff7 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -736,6 +736,36 @@ const ( // Decouples Taint Eviction Controller, performing taint-based Pod eviction, from Node Lifecycle Controller. SeparateTaintEvictionController featuregate.Feature = "SeparateTaintEvictionController" + // owner: @munnerz + // kep: http://kep.k8s.io/4193 + // alpha: v1.29 + // + // Controls whether JTIs (UUIDs) are embedded into generated service account tokens, and whether these JTIs are + // recorded into the audit log for future requests made by these tokens. + ServiceAccountTokenJTI featuregate.Feature = "ServiceAccountTokenJTI" + + // owner: @munnerz + // kep: http://kep.k8s.io/4193 + // alpha: v1.29 + // + // Controls whether the apiserver supports binding service account tokens to Node objects. + ServiceAccountTokenNodeBinding featuregate.Feature = "ServiceAccountTokenNodeBinding" + + // owner: @munnerz + // kep: http://kep.k8s.io/4193 + // alpha: v1.29 + // + // Controls whether the apiserver will validate Node claims in service account tokens. + ServiceAccountTokenNodeBindingValidation featuregate.Feature = "ServiceAccountTokenNodeBindingValidation" + + // owner: @munnerz + // kep: http://kep.k8s.io/4193 + // alpha: v1.29 + // + // Controls whether the apiserver embeds the node name and uid for the associated node when issuing + // service account tokens bound to Pod objects. + ServiceAccountTokenPodNodeInfo featuregate.Feature = "ServiceAccountTokenPodNodeInfo" + // owner: @xuzhenglun // kep: http://kep.k8s.io/3682 // alpha: v1.27 @@ -1102,6 +1132,14 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS SeparateTaintEvictionController: {Default: true, PreRelease: featuregate.Beta}, + ServiceAccountTokenJTI: {Default: false, PreRelease: featuregate.Alpha}, + + ServiceAccountTokenPodNodeInfo: {Default: false, PreRelease: featuregate.Alpha}, + + ServiceAccountTokenNodeBinding: {Default: false, PreRelease: featuregate.Alpha}, + + ServiceAccountTokenNodeBindingValidation: {Default: false, PreRelease: featuregate.Alpha}, + ServiceNodePortStaticSubrange: {Default: true, PreRelease: featuregate.GA, LockToDefault: true}, // GA in 1.29; remove in 1.31 SidecarContainers: {Default: false, PreRelease: featuregate.Alpha}, diff --git a/pkg/kubeapiserver/options/authentication.go b/pkg/kubeapiserver/options/authentication.go index b7fce0e08eb..11501a2f3cb 100644 --- a/pkg/kubeapiserver/options/authentication.go +++ b/pkg/kubeapiserver/options/authentication.go @@ -42,10 +42,12 @@ import ( utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" + v1listers "k8s.io/client-go/listers/core/v1" cliflag "k8s.io/component-base/cli/flag" "k8s.io/klog/v2" openapicommon "k8s.io/kube-openapi/pkg/common" serviceaccountcontroller "k8s.io/kubernetes/pkg/controller/serviceaccount" + "k8s.io/kubernetes/pkg/features" kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator" authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/bootstrap" @@ -600,11 +602,16 @@ func (o *BuiltInAuthenticationOptions) ApplyTo(authInfo *genericapiserver.Authen authInfo.APIAudiences = authenticator.Audiences(o.ServiceAccounts.Issuers) } + var nodeLister v1listers.NodeLister + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenNodeBindingValidation) { + nodeLister = versionedInformer.Core().V1().Nodes().Lister() + } authenticatorConfig.ServiceAccountTokenGetter = serviceaccountcontroller.NewGetterFromClient( extclient, versionedInformer.Core().V1().Secrets().Lister(), versionedInformer.Core().V1().ServiceAccounts().Lister(), versionedInformer.Core().V1().Pods().Lister(), + nodeLister, ) authenticatorConfig.SecretsWriter = extclient.CoreV1() diff --git a/pkg/registry/core/rest/storage_core.go b/pkg/registry/core/rest/storage_core.go index 0bcb04fb44a..3fabc84371a 100644 --- a/pkg/registry/core/rest/storage_core.go +++ b/pkg/registry/core/rest/storage_core.go @@ -217,7 +217,12 @@ func (p *legacyProvider) NewRESTStorage(apiResourceConfigSource serverstorage.AP // potentially override the generic serviceaccount storage with one that supports pods var serviceAccountStorage *serviceaccountstore.REST if p.ServiceAccountIssuer != nil { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, p.ServiceAccountIssuer, p.APIAudiences, p.ServiceAccountMaxExpiration, podStorage.Pod.Store, storage["secrets"].(rest.Getter), p.ExtendExpiration) + var nodeGetter rest.Getter + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenNodeBinding) || + utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenPodNodeInfo) { + nodeGetter = nodeStorage.Node.Store + } + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, p.ServiceAccountIssuer, p.APIAudiences, p.ServiceAccountMaxExpiration, podStorage.Pod.Store, storage["secrets"].(rest.Getter), nodeGetter, p.ExtendExpiration) if err != nil { return genericapiserver.APIGroupInfo{}, err } diff --git a/pkg/registry/core/rest/storage_core_generic.go b/pkg/registry/core/rest/storage_core_generic.go index 2299481e878..193b5b98f47 100644 --- a/pkg/registry/core/rest/storage_core_generic.go +++ b/pkg/registry/core/rest/storage_core_generic.go @@ -95,9 +95,9 @@ func (c *GenericConfig) NewRESTStorage(apiResourceConfigSource serverstorage.API var serviceAccountStorage *serviceaccountstore.REST if c.ServiceAccountIssuer != nil { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), secretStorage.Store, c.ExtendExpiration) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, c.ServiceAccountIssuer, c.APIAudiences, c.ServiceAccountMaxExpiration, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), secretStorage.Store, newNotFoundGetter(schema.GroupResource{Resource: "nodes"}), c.ExtendExpiration) } else { - serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), newNotFoundGetter(schema.GroupResource{Resource: "secrets"}), false) + serviceAccountStorage, err = serviceaccountstore.NewREST(restOptionsGetter, nil, nil, 0, newNotFoundGetter(schema.GroupResource{Resource: "pods"}), newNotFoundGetter(schema.GroupResource{Resource: "secrets"}), newNotFoundGetter(schema.GroupResource{Resource: "nodes"}), false) } if err != nil { return genericapiserver.APIGroupInfo{}, err diff --git a/pkg/registry/core/serviceaccount/storage/storage.go b/pkg/registry/core/serviceaccount/storage/storage.go index c4707943803..f6082bcefff 100644 --- a/pkg/registry/core/serviceaccount/storage/storage.go +++ b/pkg/registry/core/serviceaccount/storage/storage.go @@ -39,7 +39,7 @@ type REST struct { } // NewREST returns a RESTStorage object that will work against service accounts. -func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage rest.Getter, extendExpiration bool) (*REST, error) { +func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, auds authenticator.Audiences, max time.Duration, podStorage, secretStorage, nodeStorage rest.Getter, extendExpiration bool) (*REST, error) { store := &genericregistry.Store{ NewFunc: func() runtime.Object { return &api.ServiceAccount{} }, NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} }, @@ -64,6 +64,7 @@ func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator, svcaccts: store, pods: podStorage, secrets: secretStorage, + nodes: nodeStorage, issuer: issuer, auds: auds, audsSet: sets.NewString(auds...), diff --git a/pkg/registry/core/serviceaccount/storage/storage_test.go b/pkg/registry/core/serviceaccount/storage/storage_test.go index 220f5053047..9cae0884f56 100644 --- a/pkg/registry/core/serviceaccount/storage/storage_test.go +++ b/pkg/registry/core/serviceaccount/storage/storage_test.go @@ -38,7 +38,7 @@ func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) { DeleteCollectionWorkers: 1, ResourcePrefix: "serviceaccounts", } - rest, err := NewREST(restOptions, nil, nil, 0, nil, nil, false) + rest, err := NewREST(restOptions, nil, nil, 0, nil, nil, nil, false) if err != nil { t.Fatalf("unexpected error from REST storage: %v", err) } diff --git a/pkg/registry/core/serviceaccount/storage/token.go b/pkg/registry/core/serviceaccount/storage/token.go index 45cb723ed61..5335afb8901 100644 --- a/pkg/registry/core/serviceaccount/storage/token.go +++ b/pkg/registry/core/serviceaccount/storage/token.go @@ -29,13 +29,18 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/audit" "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/serviceaccount" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" + utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/apiserver/pkg/warning" + "k8s.io/klog/v2" authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" authenticationvalidation "k8s.io/kubernetes/pkg/apis/authentication/validation" api "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/features" token "k8s.io/kubernetes/pkg/serviceaccount" ) @@ -53,6 +58,7 @@ type TokenREST struct { svcaccts rest.Getter pods rest.Getter secrets rest.Getter + nodes rest.Getter issuer token.TokenGenerator auds authenticator.Audiences audsSet sets.String @@ -127,6 +133,7 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, var ( pod *api.Pod + node *api.Node secret *api.Secret ) @@ -136,7 +143,7 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, gvk := schema.FromAPIVersionAndKind(ref.APIVersion, ref.Kind) switch { case gvk.Group == "" && gvk.Kind == "Pod": - newCtx := newContext(ctx, "pods", ref.Name, gvk) + newCtx := newContext(ctx, "pods", ref.Name, namespace, gvk) podObj, err := r.pods.Get(newCtx, ref.Name, &metav1.GetOptions{}) if err != nil { return nil, err @@ -146,8 +153,41 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, return nil, errors.NewBadRequest(fmt.Sprintf("cannot bind token for serviceaccount %q to pod running with different serviceaccount name.", name)) } uid = pod.UID + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenPodNodeInfo) { + if nodeName := pod.Spec.NodeName; nodeName != "" { + newCtx := newContext(ctx, "nodes", nodeName, "", api.SchemeGroupVersion.WithKind("Node")) + // set ResourceVersion=0 to allow this to be read/served from the apiservers watch cache + nodeObj, err := r.nodes.Get(newCtx, nodeName, &metav1.GetOptions{ResourceVersion: "0"}) + if err != nil { + nodeObj, err = r.nodes.Get(newCtx, nodeName, &metav1.GetOptions{}) // fallback to a live lookup on any error + } + switch { + case errors.IsNotFound(err): + // if the referenced Node object does not exist, we still embed just the pod name into the + // claims so that clients still have some indication of what node a pod is assigned to when + // inspecting a token (even if the UID is not present). + klog.V(4).ErrorS(err, "failed fetching node for pod", "pod", klog.KObj(pod), "podUID", pod.UID, "nodeName", nodeName) + node = &api.Node{ObjectMeta: metav1.ObjectMeta{Name: nodeName}} + case err != nil: + return nil, errors.NewInternalError(err) + default: + node = nodeObj.(*api.Node) + } + } + } + case gvk.Group == "" && gvk.Kind == "Node": + if !utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenNodeBinding) { + return nil, errors.NewBadRequest(fmt.Sprintf("cannot bind token to a Node object as the %q feature-gate is disabled", features.ServiceAccountTokenNodeBinding)) + } + newCtx := newContext(ctx, "nodes", ref.Name, "", gvk) + nodeObj, err := r.nodes.Get(newCtx, ref.Name, &metav1.GetOptions{}) + if err != nil { + return nil, err + } + node = nodeObj.(*api.Node) + uid = node.UID case gvk.Group == "" && gvk.Kind == "Secret": - newCtx := newContext(ctx, "secrets", ref.Name, gvk) + newCtx := newContext(ctx, "secrets", ref.Name, namespace, gvk) secretObj, err := r.secrets.Get(newCtx, ref.Name, &metav1.GetOptions{}) if err != nil { return nil, err @@ -179,7 +219,10 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, exp = token.ExpirationExtensionSeconds } - sc, pc := token.Claims(*svcacct, pod, secret, exp, warnAfter, req.Spec.Audiences) + sc, pc, err := token.Claims(*svcacct, pod, secret, node, exp, warnAfter, req.Spec.Audiences) + if err != nil { + return nil, err + } tokdata, err := r.issuer.GenerateToken(sc, pc) if err != nil { return nil, fmt.Errorf("failed to generate token: %v", err) @@ -191,6 +234,9 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, Token: tokdata, ExpirationTimestamp: metav1.Time{Time: nowTime.Add(time.Duration(out.Spec.ExpirationSeconds) * time.Second)}, } + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenJTI) && len(sc.ID) > 0 { + audit.AddAuditAnnotation(ctx, serviceaccount.CredentialIDKey, serviceaccount.CredentialIDForJTI(sc.ID)) + } return out, nil } @@ -199,15 +245,11 @@ func (r *TokenREST) GroupVersionKind(schema.GroupVersion) schema.GroupVersionKin } // newContext return a copy of ctx in which new RequestInfo is set -func newContext(ctx context.Context, resource, name string, gvk schema.GroupVersionKind) context.Context { - oldInfo, found := genericapirequest.RequestInfoFrom(ctx) - if !found { - return ctx - } +func newContext(ctx context.Context, resource, name, namespace string, gvk schema.GroupVersionKind) context.Context { newInfo := genericapirequest.RequestInfo{ IsResourceRequest: true, Verb: "get", - Namespace: oldInfo.Namespace, + Namespace: namespace, Resource: resource, Name: name, Parts: []string{resource, name}, diff --git a/pkg/serviceaccount/claims.go b/pkg/serviceaccount/claims.go index 76bb8b10cd9..61128579bce 100644 --- a/pkg/serviceaccount/claims.go +++ b/pkg/serviceaccount/claims.go @@ -22,12 +22,15 @@ import ( "fmt" "time" + "github.com/google/uuid" "gopkg.in/square/go-jose.v2/jwt" - "k8s.io/apiserver/pkg/audit" "k8s.io/klog/v2" + "k8s.io/apiserver/pkg/audit" apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount" + utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/features" ) const ( @@ -38,8 +41,12 @@ const ( ExpirationExtensionSeconds = 24 * 365 * 60 * 60 ) -// time.Now stubbed out to allow testing -var now = time.Now +var ( + // time.Now stubbed out to allow testing + now = time.Now + // uuid.New stubbed out to allow testing + newUUID = uuid.NewString +) type privateClaims struct { Kubernetes kubernetes `json:"kubernetes.io,omitempty"` @@ -50,6 +57,7 @@ type kubernetes struct { Svcacct ref `json:"serviceaccount,omitempty"` Pod *ref `json:"pod,omitempty"` Secret *ref `json:"secret,omitempty"` + Node *ref `json:"node,omitempty"` WarnAfter *jwt.NumericDate `json:"warnafter,omitempty"` } @@ -58,7 +66,7 @@ type ref struct { UID string `json:"uid,omitempty"` } -func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirationSeconds, warnafter int64, audience []string) (*jwt.Claims, interface{}) { +func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, node *core.Node, expirationSeconds, warnafter int64, audience []string) (*jwt.Claims, interface{}, error) { now := now() sc := &jwt.Claims{ Subject: apiserverserviceaccount.MakeUsername(sa.Namespace, sa.Name), @@ -67,6 +75,9 @@ func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirati NotBefore: jwt.NewNumericDate(now), Expiry: jwt.NewNumericDate(now.Add(time.Duration(expirationSeconds) * time.Second)), } + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenJTI) { + sc.ID = newUUID() + } pc := &privateClaims{ Kubernetes: kubernetes{ Namespace: sa.Namespace, @@ -76,24 +87,45 @@ func Claims(sa core.ServiceAccount, pod *core.Pod, secret *core.Secret, expirati }, }, } + + if secret != nil && (node != nil || pod != nil) { + return nil, nil, fmt.Errorf("internal error, token can only be bound to one object type") + } switch { case pod != nil: pc.Kubernetes.Pod = &ref{ Name: pod.Name, UID: string(pod.UID), } + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenPodNodeInfo) { + // if this is bound to a pod and the node information is available, persist that too + if node != nil { + pc.Kubernetes.Node = &ref{ + Name: node.Name, + UID: string(node.UID), + } + } + } case secret != nil: pc.Kubernetes.Secret = &ref{ Name: secret.Name, UID: string(secret.UID), } + case node != nil: + if !utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenNodeBinding) { + return nil, nil, fmt.Errorf("token bound to Node object requested, but %q feature gate is disabled", features.ServiceAccountTokenNodeBinding) + } + pc.Kubernetes.Node = &ref{ + Name: node.Name, + UID: string(node.UID), + } } if warnafter != 0 { pc.Kubernetes.WarnAfter = jwt.NewNumericDate(now.Add(time.Duration(warnafter) * time.Second)) } - return sc, pc + return sc, pc, nil } func NewValidator(getter ServiceAccountTokenGetter) Validator { @@ -146,6 +178,7 @@ func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, namespace := private.Kubernetes.Namespace saref := private.Kubernetes.Svcacct podref := private.Kubernetes.Pod + noderef := private.Kubernetes.Node secref := private.Kubernetes.Secret // Make sure service account still exists (name and UID) serviceAccount, err := v.getter.GetServiceAccount(namespace, saref.Name) @@ -153,14 +186,15 @@ func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, klog.V(4).Infof("Could not retrieve service account %s/%s: %v", namespace, saref.Name, err) return nil, err } - if serviceAccount.DeletionTimestamp != nil && serviceAccount.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { - klog.V(4).Infof("Service account has been deleted %s/%s", namespace, saref.Name) - return nil, fmt.Errorf("service account %s/%s has been deleted", namespace, saref.Name) - } + if string(serviceAccount.UID) != saref.UID { klog.V(4).Infof("Service account UID no longer matches %s/%s: %q != %q", namespace, saref.Name, string(serviceAccount.UID), saref.UID) return nil, fmt.Errorf("service account UID (%s) does not match claim (%s)", serviceAccount.UID, saref.UID) } + if serviceAccount.DeletionTimestamp != nil && serviceAccount.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { + klog.V(4).Infof("Service account has been deleted %s/%s", namespace, saref.Name) + return nil, fmt.Errorf("service account %s/%s has been deleted", namespace, saref.Name) + } if secref != nil { // Make sure token hasn't been invalidated by deletion of the secret @@ -169,14 +203,14 @@ func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, klog.V(4).Infof("Could not retrieve bound secret %s/%s for service account %s/%s: %v", namespace, secref.Name, namespace, saref.Name, err) return nil, errors.New("service account token has been invalidated") } - if secret.DeletionTimestamp != nil && secret.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { - klog.V(4).Infof("Bound secret is deleted and awaiting removal: %s/%s for service account %s/%s", namespace, secref.Name, namespace, saref.Name) - return nil, errors.New("service account token has been invalidated") - } if secref.UID != string(secret.UID) { klog.V(4).Infof("Secret UID no longer matches %s/%s: %q != %q", namespace, secref.Name, string(secret.UID), secref.UID) return nil, fmt.Errorf("secret UID (%s) does not match service account secret ref claim (%s)", secret.UID, secref.UID) } + if secret.DeletionTimestamp != nil && secret.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { + klog.V(4).Infof("Bound secret is deleted and awaiting removal: %s/%s for service account %s/%s", namespace, secref.Name, namespace, saref.Name) + return nil, errors.New("service account token has been invalidated") + } } var podName, podUID string @@ -187,18 +221,51 @@ func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, klog.V(4).Infof("Could not retrieve bound pod %s/%s for service account %s/%s: %v", namespace, podref.Name, namespace, saref.Name, err) return nil, errors.New("service account token has been invalidated") } - if pod.DeletionTimestamp != nil && pod.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { - klog.V(4).Infof("Bound pod is deleted and awaiting removal: %s/%s for service account %s/%s", namespace, podref.Name, namespace, saref.Name) - return nil, errors.New("service account token has been invalidated") - } if podref.UID != string(pod.UID) { klog.V(4).Infof("Pod UID no longer matches %s/%s: %q != %q", namespace, podref.Name, string(pod.UID), podref.UID) return nil, fmt.Errorf("pod UID (%s) does not match service account pod ref claim (%s)", pod.UID, podref.UID) } + if pod.DeletionTimestamp != nil && pod.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { + klog.V(4).Infof("Bound pod is deleted and awaiting removal: %s/%s for service account %s/%s", namespace, podref.Name, namespace, saref.Name) + return nil, errors.New("service account token has been invalidated") + } podName = podref.Name podUID = podref.UID } + var nodeName, nodeUID string + if noderef != nil { + switch { + case podref != nil: + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenPodNodeInfo) { + // for pod-bound tokens, just extract the node claims + nodeName = noderef.Name + nodeUID = noderef.UID + } + case podref == nil: + if !utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenNodeBindingValidation) { + klog.V(4).Infof("ServiceAccount token is bound to a Node object, but the node bound token validation feature is disabled") + return nil, fmt.Errorf("token is bound to a Node object but the %s feature gate is disabled", features.ServiceAccountTokenNodeBindingValidation) + } + + node, err := v.getter.GetNode(noderef.Name) + if err != nil { + klog.V(4).Infof("Could not retrieve node object %q for service account %s/%s: %v", noderef.Name, namespace, saref.Name, err) + return nil, errors.New("service account token has been invalidated") + } + if noderef.UID != string(node.UID) { + klog.V(4).Infof("Node UID no longer matches %s: %q != %q", noderef.Name, string(node.UID), noderef.UID) + return nil, fmt.Errorf("node UID (%s) does not match service account node ref claim (%s)", node.UID, noderef.UID) + } + if node.DeletionTimestamp != nil && node.DeletionTimestamp.Time.Before(invalidIfDeletedBefore) { + klog.V(4).Infof("Node %q is deleted and awaiting removal for service account %s/%s", node.Name, namespace, saref.Name) + return nil, errors.New("service account token has been invalidated") + } + nodeName = noderef.Name + nodeUID = noderef.UID + } + } + // Check special 'warnafter' field for projected service account token transition. warnafter := private.Kubernetes.WarnAfter if warnafter != nil && *warnafter != 0 { @@ -212,12 +279,19 @@ func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims, } } + var jti string + if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenJTI) { + jti = public.ID + } return &apiserverserviceaccount.ServiceAccountInfo{ - Namespace: private.Kubernetes.Namespace, - Name: private.Kubernetes.Svcacct.Name, - UID: private.Kubernetes.Svcacct.UID, - PodName: podName, - PodUID: podUID, + Namespace: private.Kubernetes.Namespace, + Name: private.Kubernetes.Svcacct.Name, + UID: private.Kubernetes.Svcacct.UID, + PodName: podName, + PodUID: podUID, + NodeName: nodeName, + NodeUID: nodeUID, + CredentialID: apiserverserviceaccount.CredentialIDForJTI(jti), }, nil } diff --git a/pkg/serviceaccount/claims_test.go b/pkg/serviceaccount/claims_test.go index 1a023a19c8b..f0a1cef3e95 100644 --- a/pkg/serviceaccount/claims_test.go +++ b/pkg/serviceaccount/claims_test.go @@ -29,7 +29,10 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/features" ) func init() { @@ -37,6 +40,11 @@ func init() { // epoch time: 1514764800 return time.Date(2018, time.January, 1, 0, 0, 0, 0, time.UTC) } + + newUUID = func() string { + // always return a fixed/static UUID for testing + return "fixed" + } } func TestClaims(t *testing.T) { @@ -61,17 +69,27 @@ func TestClaims(t *testing.T) { UID: "mysecret-uid", }, } + node := &core.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mynode", + UID: "mynode-uid", + }, + } cs := []struct { // input sa core.ServiceAccount pod *core.Pod sec *core.Secret + node *core.Node exp int64 warnafter int64 aud []string + err string // desired sc *jwt.Claims pc *privateClaims + + featureJTI, featurePodNodeInfo, featureNodeBinding bool }{ { // pod and secret @@ -82,20 +100,7 @@ func TestClaims(t *testing.T) { exp: 0, // nil audience aud: nil, - - sc: &jwt.Claims{ - Subject: "system:serviceaccount:myns:mysvcacct", - IssuedAt: jwt.NewNumericDate(time.Unix(1514764800, 0)), - NotBefore: jwt.NewNumericDate(time.Unix(1514764800, 0)), - Expiry: jwt.NewNumericDate(time.Unix(1514764800, 0)), - }, - pc: &privateClaims{ - Kubernetes: kubernetes{ - Namespace: "myns", - Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"}, - Pod: &ref{Name: "mypod", UID: "mypod-uid"}, - }, - }, + err: "internal error, token can only be bound to one object type", }, { // pod @@ -167,7 +172,6 @@ func TestClaims(t *testing.T) { // warn after provided sa: sa, pod: pod, - sec: sec, exp: 60 * 60 * 24, warnafter: 60 * 60, // nil audience @@ -188,6 +192,141 @@ func TestClaims(t *testing.T) { }, }, }, + { + // node with feature gate disabled + sa: sa, + node: node, + // really fast + exp: 0, + // nil audience + aud: nil, + err: "token bound to Node object requested, but \"ServiceAccountTokenNodeBinding\" feature gate is disabled", + }, + { + // node & pod with feature gate disabled + sa: sa, + node: node, + pod: pod, + // really fast + exp: 0, + // nil audience + aud: nil, + + sc: &jwt.Claims{ + Subject: "system:serviceaccount:myns:mysvcacct", + IssuedAt: jwt.NewNumericDate(time.Unix(1514764800, 0)), + NotBefore: jwt.NewNumericDate(time.Unix(1514764800, 0)), + Expiry: jwt.NewNumericDate(time.Unix(1514764800, 0)), + }, + pc: &privateClaims{ + Kubernetes: kubernetes{ + Namespace: "myns", + Pod: &ref{Name: "mypod", UID: "mypod-uid"}, + Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"}, + }, + }, + }, + { + // node alone + sa: sa, + node: node, + // enable node binding feature + featureNodeBinding: true, + // really fast + exp: 0, + // nil audience + aud: nil, + + sc: &jwt.Claims{ + Subject: "system:serviceaccount:myns:mysvcacct", + IssuedAt: jwt.NewNumericDate(time.Unix(1514764800, 0)), + NotBefore: jwt.NewNumericDate(time.Unix(1514764800, 0)), + Expiry: jwt.NewNumericDate(time.Unix(1514764800, 0)), + }, + pc: &privateClaims{ + Kubernetes: kubernetes{ + Namespace: "myns", + Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"}, + Node: &ref{Name: "mynode", UID: "mynode-uid"}, + }, + }, + }, + { + // node and pod + sa: sa, + pod: pod, + node: node, + // enable embedding pod node info feature + featurePodNodeInfo: true, + // really fast + exp: 0, + // nil audience + aud: nil, + + sc: &jwt.Claims{ + Subject: "system:serviceaccount:myns:mysvcacct", + IssuedAt: jwt.NewNumericDate(time.Unix(1514764800, 0)), + NotBefore: jwt.NewNumericDate(time.Unix(1514764800, 0)), + Expiry: jwt.NewNumericDate(time.Unix(1514764800, 0)), + }, + pc: &privateClaims{ + Kubernetes: kubernetes{ + Namespace: "myns", + Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"}, + Pod: &ref{Name: "mypod", UID: "mypod-uid"}, + Node: &ref{Name: "mynode", UID: "mynode-uid"}, + }, + }, + }, + { + // node and secret should error + sa: sa, + sec: sec, + node: node, + // enable embedding node info feature + featureNodeBinding: true, + // really fast + exp: 0, + // nil audience + aud: nil, + err: "internal error, token can only be bound to one object type", + }, + { + // ensure JTI is set + sa: sa, + // enable setting JTI feature + featureJTI: true, + // really fast + exp: 0, + // nil audience + aud: nil, + + sc: &jwt.Claims{ + Subject: "system:serviceaccount:myns:mysvcacct", + IssuedAt: jwt.NewNumericDate(time.Unix(1514764800, 0)), + NotBefore: jwt.NewNumericDate(time.Unix(1514764800, 0)), + Expiry: jwt.NewNumericDate(time.Unix(1514764800, 0)), + ID: "fixed", + }, + pc: &privateClaims{ + Kubernetes: kubernetes{ + Namespace: "myns", + Svcacct: ref{Name: "mysvcacct", UID: "mysvcacct-uid"}, + }, + }, + }, + { + // ensure it fails if node binding gate is disabled + sa: sa, + node: node, + featureNodeBinding: false, + // really fast + exp: 0, + // nil audience + aud: nil, + + err: "token bound to Node object requested, but \"ServiceAccountTokenNodeBinding\" feature gate is disabled", + }, } for i, c := range cs { t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { @@ -202,7 +341,18 @@ func TestClaims(t *testing.T) { return string(b) } - sc, pc := Claims(c.sa, c.pod, c.sec, c.exp, c.warnafter, c.aud) + // set feature flags for the duration of the test case + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenJTI, c.featureJTI)() + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenNodeBinding, c.featureNodeBinding)() + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenPodNodeInfo, c.featurePodNodeInfo)() + + sc, pc, err := Claims(c.sa, c.pod, c.sec, c.node, c.exp, c.warnafter, c.aud) + if err != nil && err.Error() != c.err { + t.Errorf("expected error %q but got: %v", c.err, err) + } + if err == nil && c.err != "" { + t.Errorf("expected an error but got none") + } if spew(sc) != spew(c.sc) { t.Errorf("standard claims differed\n\tsaw:\t%s\n\twant:\t%s", spew(sc), spew(c.sc)) } @@ -226,6 +376,8 @@ type claimTestCase struct { expiry jwt.NumericDate notBefore jwt.NumericDate expectErr string + + featureNodeBindingValidation bool } func TestValidatePrivateClaims(t *testing.T) { @@ -235,6 +387,7 @@ func TestValidatePrivateClaims(t *testing.T) { serviceAccount = &v1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "saname", Namespace: "ns", UID: "sauid"}} secret = &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secretname", Namespace: "ns", UID: "secretuid"}} pod = &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "podname", Namespace: "ns", UID: "poduid"}} + node = &v1.Node{ObjectMeta: metav1.ObjectMeta{Name: "nodename", UID: "nodeuid"}} ) deletionTestCases := []deletionTestCase{ @@ -268,57 +421,64 @@ func TestValidatePrivateClaims(t *testing.T) { testcases := []claimTestCase{ { name: "good", - getter: fakeGetter{serviceAccount, nil, nil}, + getter: fakeGetter{serviceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}}, expectErr: "", }, { name: "expired", - getter: fakeGetter{serviceAccount, nil, nil}, + getter: fakeGetter{serviceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}}, expiry: *jwt.NewNumericDate(now().Add(-1_000 * time.Hour)), expectErr: "service account token has expired", }, { name: "not yet valid", - getter: fakeGetter{serviceAccount, nil, nil}, + getter: fakeGetter{serviceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}}, notBefore: *jwt.NewNumericDate(now().Add(1_000 * time.Hour)), expectErr: "service account token is not valid yet", }, { name: "missing serviceaccount", - getter: fakeGetter{nil, nil, nil}, + getter: fakeGetter{nil, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}}, expectErr: `serviceaccounts "saname" not found`, }, { name: "missing secret", - getter: fakeGetter{serviceAccount, nil, nil}, + getter: fakeGetter{serviceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Secret: &ref{Name: "secretname", UID: "secretuid"}, Namespace: "ns"}}, expectErr: "service account token has been invalidated", }, { name: "missing pod", - getter: fakeGetter{serviceAccount, nil, nil}, + getter: fakeGetter{serviceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Pod: &ref{Name: "podname", UID: "poduid"}, Namespace: "ns"}}, expectErr: "service account token has been invalidated", }, + { + name: "missing node", + getter: fakeGetter{serviceAccount, nil, nil, nil}, + private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Node: &ref{Name: "nodename", UID: "nodeuid"}, Namespace: "ns"}}, + expectErr: "service account token has been invalidated", + featureNodeBindingValidation: true, + }, { name: "different uid serviceaccount", - getter: fakeGetter{serviceAccount, nil, nil}, + getter: fakeGetter{serviceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauidold"}, Namespace: "ns"}}, expectErr: "service account UID (sauid) does not match claim (sauidold)", }, { name: "different uid secret", - getter: fakeGetter{serviceAccount, secret, nil}, + getter: fakeGetter{serviceAccount, secret, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Secret: &ref{Name: "secretname", UID: "secretuidold"}, Namespace: "ns"}}, expectErr: "secret UID (secretuid) does not match service account secret ref claim (secretuidold)", }, { name: "different uid pod", - getter: fakeGetter{serviceAccount, nil, pod}, + getter: fakeGetter{serviceAccount, nil, pod, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Pod: &ref{Name: "podname", UID: "poduidold"}, Namespace: "ns"}}, expectErr: "pod UID (poduid) does not match service account pod ref claim (poduidold)", }, @@ -329,10 +489,12 @@ func TestValidatePrivateClaims(t *testing.T) { deletedServiceAccount = serviceAccount.DeepCopy() deletedPod = pod.DeepCopy() deletedSecret = secret.DeepCopy() + deletedNode = node.DeepCopy() ) deletedServiceAccount.DeletionTimestamp = deletionTestCase.time deletedPod.DeletionTimestamp = deletionTestCase.time deletedSecret.DeletionTimestamp = deletionTestCase.time + deletedNode.DeletionTimestamp = deletionTestCase.time var saDeletedErr, deletedErr string if deletionTestCase.expectErr { @@ -343,32 +505,42 @@ func TestValidatePrivateClaims(t *testing.T) { testcases = append(testcases, claimTestCase{ name: deletionTestCase.name + " serviceaccount", - getter: fakeGetter{deletedServiceAccount, nil, nil}, + getter: fakeGetter{deletedServiceAccount, nil, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}}, expectErr: saDeletedErr, }, claimTestCase{ name: deletionTestCase.name + " secret", - getter: fakeGetter{serviceAccount, deletedSecret, nil}, + getter: fakeGetter{serviceAccount, deletedSecret, nil, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Secret: &ref{Name: "secretname", UID: "secretuid"}, Namespace: "ns"}}, expectErr: deletedErr, }, claimTestCase{ name: deletionTestCase.name + " pod", - getter: fakeGetter{serviceAccount, nil, deletedPod}, + getter: fakeGetter{serviceAccount, nil, deletedPod, nil}, private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Pod: &ref{Name: "podname", UID: "poduid"}, Namespace: "ns"}}, expectErr: deletedErr, }, + claimTestCase{ + name: deletionTestCase.name + " node", + getter: fakeGetter{serviceAccount, nil, nil, deletedNode}, + private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Node: &ref{Name: "nodename", UID: "nodeuid"}, Namespace: "ns"}}, + expectErr: deletedErr, + featureNodeBindingValidation: true, + }, ) } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - v := &validator{tc.getter} + v := &validator{getter: tc.getter} expiry := jwt.NumericDate(nowUnix) if tc.expiry != 0 { expiry = tc.expiry } + + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenNodeBindingValidation, tc.featureNodeBindingValidation)() + _, err := v.Validate(context.Background(), "", &jwt.Claims{Expiry: &expiry, NotBefore: &tc.notBefore}, tc.private) if len(tc.expectErr) > 0 { if errStr := errString(err); tc.expectErr != errStr { @@ -393,6 +565,7 @@ type fakeGetter struct { serviceAccount *v1.ServiceAccount secret *v1.Secret pod *v1.Pod + node *v1.Node } func (f fakeGetter) GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error) { @@ -413,3 +586,9 @@ func (f fakeGetter) GetSecret(namespace, name string) (*v1.Secret, error) { } return f.secret, nil } +func (f fakeGetter) GetNode(name string) (*v1.Node, error) { + if f.node == nil { + return nil, apierrors.NewNotFound(schema.GroupResource{Group: "", Resource: "nodes"}, name) + } + return f.node, nil +} diff --git a/pkg/serviceaccount/jwt.go b/pkg/serviceaccount/jwt.go index 6722b206d1d..57ae0a59b99 100644 --- a/pkg/serviceaccount/jwt.go +++ b/pkg/serviceaccount/jwt.go @@ -43,6 +43,7 @@ type ServiceAccountTokenGetter interface { GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error) GetPod(namespace, name string) (*v1.Pod, error) GetSecret(namespace, name string) (*v1.Secret, error) + GetNode(name string) (*v1.Node, error) } type TokenGenerator interface { diff --git a/pkg/serviceaccount/jwt_test.go b/pkg/serviceaccount/jwt_test.go index b6e58c32755..629bb8496ab 100644 --- a/pkg/serviceaccount/jwt_test.go +++ b/pkg/serviceaccount/jwt_test.go @@ -371,6 +371,9 @@ func TestTokenGenerateAndValidate(t *testing.T) { v1listers.NewPodLister(newIndexer(func(namespace, name string) (interface{}, error) { return tc.Client.CoreV1().Pods(namespace).Get(context.TODO(), name, metav1.GetOptions{}) })), + v1listers.NewNodeLister(newIndexer(func(_, name string) (interface{}, error) { + return tc.Client.CoreV1().Nodes().Get(context.TODO(), name, metav1.GetOptions{}) + })), ) var secretsWriter typedv1core.SecretsGetter if tc.Client != nil { @@ -443,6 +446,11 @@ func (f *fakeIndexer) GetByKey(key string) (interface{}, bool, error) { parts := strings.SplitN(key, "/", 2) namespace := parts[0] name := "" + // implies the key does not contain a / (this is a cluster-scoped object) + if len(parts) == 1 { + name = parts[0] + namespace = "" + } if len(parts) == 2 { name = parts[1] } diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util.go b/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util.go index f0dc0767639..c55fe5d2ed6 100644 --- a/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util.go +++ b/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util.go @@ -36,12 +36,21 @@ const ( ServiceAccountUsernameSeparator = ":" ServiceAccountGroupPrefix = "system:serviceaccounts:" AllServiceAccountsGroup = "system:serviceaccounts" + // CredentialIDKey is the key used in a user's "extra" to specify the unique + // identifier for this identity document). + CredentialIDKey = "authentication.kubernetes.io/credential-id" // PodNameKey is the key used in a user's "extra" to specify the pod name of // the authenticating request. PodNameKey = "authentication.kubernetes.io/pod-name" // PodUIDKey is the key used in a user's "extra" to specify the pod UID of // the authenticating request. PodUIDKey = "authentication.kubernetes.io/pod-uid" + // NodeNameKey is the key used in a user's "extra" to specify the node name of + // the authenticating request. + NodeNameKey = "authentication.kubernetes.io/node-name" + // NodeUIDKey is the key used in a user's "extra" to specify the node UID of + // the authenticating request. + NodeUIDKey = "authentication.kubernetes.io/node-uid" ) // MakeUsername generates a username from the given namespace and ServiceAccount name. @@ -119,6 +128,8 @@ func UserInfo(namespace, name, uid string) user.Info { type ServiceAccountInfo struct { Name, Namespace, UID string PodName, PodUID string + CredentialID string + NodeName, NodeUID string } func (sa *ServiceAccountInfo) UserInfo() user.Info { @@ -127,15 +138,43 @@ func (sa *ServiceAccountInfo) UserInfo() user.Info { UID: sa.UID, Groups: MakeGroupNames(sa.Namespace), } + if sa.PodName != "" && sa.PodUID != "" { - info.Extra = map[string][]string{ - PodNameKey: {sa.PodName}, - PodUIDKey: {sa.PodUID}, + if info.Extra == nil { + info.Extra = make(map[string][]string) + } + info.Extra[PodNameKey] = []string{sa.PodName} + info.Extra[PodUIDKey] = []string{sa.PodUID} + } + if sa.CredentialID != "" { + if info.Extra == nil { + info.Extra = make(map[string][]string) + } + info.Extra[CredentialIDKey] = []string{sa.CredentialID} + } + if sa.NodeName != "" { + if info.Extra == nil { + info.Extra = make(map[string][]string) + } + info.Extra[NodeNameKey] = []string{sa.NodeName} + // node UID is optional and will only be set if the node name is set + if sa.NodeUID != "" { + info.Extra[NodeUIDKey] = []string{sa.NodeUID} } } + return info } +// CredentialIDForJTI converts a given JTI string into a credential identifier for use in a +// users 'extra' info. +func CredentialIDForJTI(jti string) string { + if len(jti) == 0 { + return "" + } + return "JTI=" + jti +} + // IsServiceAccountToken returns true if the secret is a valid api token for the service account func IsServiceAccountToken(secret *v1.Secret, sa *v1.ServiceAccount) bool { if secret.Type != v1.SecretTypeServiceAccountToken { diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util_test.go b/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util_test.go index 50a7eb97b1f..8d6854848ae 100644 --- a/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util_test.go +++ b/staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount/util_test.go @@ -17,12 +17,70 @@ limitations under the License. package serviceaccount import ( + "reflect" "testing" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" ) +func TestUserInfo(t *testing.T) { + tests := map[string]struct { + info ServiceAccountInfo + expectedUserInfo *user.DefaultInfo + }{ + "extracts pod name/uid": { + info: ServiceAccountInfo{Name: "name", Namespace: "ns", PodName: "test", PodUID: "uid"}, + expectedUserInfo: &user.DefaultInfo{ + Name: "system:serviceaccount:ns:name", + Groups: []string{"system:serviceaccounts", "system:serviceaccounts:ns"}, + Extra: map[string][]string{ + "authentication.kubernetes.io/pod-name": {"test"}, + "authentication.kubernetes.io/pod-uid": {"uid"}, + }, + }, + }, + "extracts node name/uid": { + info: ServiceAccountInfo{Name: "name", Namespace: "ns", NodeName: "test", NodeUID: "uid"}, + expectedUserInfo: &user.DefaultInfo{ + Name: "system:serviceaccount:ns:name", + Groups: []string{"system:serviceaccounts", "system:serviceaccounts:ns"}, + Extra: map[string][]string{ + "authentication.kubernetes.io/node-name": {"test"}, + "authentication.kubernetes.io/node-uid": {"uid"}, + }, + }, + }, + "extracts node name only": { + info: ServiceAccountInfo{Name: "name", Namespace: "ns", NodeName: "test"}, + expectedUserInfo: &user.DefaultInfo{ + Name: "system:serviceaccount:ns:name", + Groups: []string{"system:serviceaccounts", "system:serviceaccounts:ns"}, + Extra: map[string][]string{ + "authentication.kubernetes.io/node-name": {"test"}, + }, + }, + }, + "does not extract node UID if name is not set": { + info: ServiceAccountInfo{Name: "name", Namespace: "ns", NodeUID: "test"}, + expectedUserInfo: &user.DefaultInfo{ + Name: "system:serviceaccount:ns:name", + Groups: []string{"system:serviceaccounts", "system:serviceaccounts:ns"}, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + userInfo := test.info.UserInfo() + if !reflect.DeepEqual(userInfo, test.expectedUserInfo) { + t.Errorf("expected %#v but got %#v", test.expectedUserInfo, userInfo) + } + }) + } +} + func TestMakeUsername(t *testing.T) { testCases := map[string]struct { diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go index e2c7a7270ed..63f52169d1c 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go @@ -19,6 +19,7 @@ package create import ( "context" "fmt" + "os" "strings" "time" @@ -96,12 +97,18 @@ var ( # Request a token bound to an instance of a Secret object with a specific UID kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret --bound-object-uid 0d4691ed-659b-4935-a832-355f77ee47cc `) +) - boundObjectKindToAPIVersion = map[string]string{ +func boundObjectKindToAPIVersions() map[string]string { + kinds := map[string]string{ "Pod": "v1", "Secret": "v1", } -) + if os.Getenv("KUBECTL_NODE_BOUND_TOKENS") == "true" { + kinds["Node"] = "v1" + } + return kinds +} func NewTokenOpts(ioStreams genericiooptions.IOStreams) *TokenOptions { return &TokenOptions{ @@ -144,7 +151,7 @@ func NewCmdCreateToken(f cmdutil.Factory, ioStreams genericiooptions.IOStreams) cmd.Flags().DurationVar(&o.Duration, "duration", o.Duration, "Requested lifetime of the issued token. If not set, the lifetime will be determined by the server automatically. The server may return a token with a longer or shorter lifetime.") cmd.Flags().StringVar(&o.BoundObjectKind, "bound-object-kind", o.BoundObjectKind, "Kind of an object to bind the token to. "+ - "Supported kinds are "+strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")+". "+ + "Supported kinds are "+strings.Join(sets.StringKeySet(boundObjectKindToAPIVersions()).List(), ", ")+". "+ "If set, --bound-object-name must be provided.") cmd.Flags().StringVar(&o.BoundObjectName, "bound-object-name", o.BoundObjectName, "Name of an object to bind the token to. "+ "The token will expire when the object is deleted. "+ @@ -221,8 +228,8 @@ func (o *TokenOptions) Validate() error { return fmt.Errorf("--bound-object-uid can only be set if --bound-object-kind is provided") } } else { - if _, ok := boundObjectKindToAPIVersion[o.BoundObjectKind]; !ok { - return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")) + if _, ok := boundObjectKindToAPIVersions()[o.BoundObjectKind]; !ok { + return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersions()).List(), ", ")) } if len(o.BoundObjectName) == 0 { return fmt.Errorf("--bound-object-name is required if --bound-object-kind is provided") @@ -245,7 +252,7 @@ func (o *TokenOptions) Run() error { if len(o.BoundObjectKind) > 0 { request.Spec.BoundObjectRef = &authenticationv1.BoundObjectReference{ Kind: o.BoundObjectKind, - APIVersion: boundObjectKindToAPIVersion[o.BoundObjectKind], + APIVersion: boundObjectKindToAPIVersions()[o.BoundObjectKind], Name: o.BoundObjectName, UID: types.UID(o.BoundObjectUID), } diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go index 8e3a36e3bf6..7df27dec16d 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go @@ -21,6 +21,7 @@ import ( "encoding/json" "io" "net/http" + "os" "reflect" "testing" "time" @@ -53,6 +54,8 @@ func TestCreateToken(t *testing.T) { audiences []string duration time.Duration + enableNodeBindingFeature bool + serverResponseToken string serverResponseError string @@ -117,6 +120,13 @@ status: boundObjectKind: "Foo", expectStderr: `error: supported --bound-object-kind values are Pod, Secret`, }, + { + test: "bad bound object kind (node feature enabled)", + name: "mysa", + enableNodeBindingFeature: true, + boundObjectKind: "Foo", + expectStderr: `error: supported --bound-object-kind values are Node, Pod, Secret`, + }, { test: "missing bound object name", name: "mysa", @@ -158,7 +168,30 @@ status: serverResponseToken: "abc", expectStdout: "abc", }, + { + test: "valid bound object (Node)", + name: "mysa", + enableNodeBindingFeature: true, + boundObjectKind: "Node", + boundObjectName: "mynode", + boundObjectUID: "myuid", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + Spec: authenticationv1.TokenRequestSpec{ + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Node", + APIVersion: "v1", + Name: "mynode", + UID: "myuid", + }, + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, { test: "invalid audience", name: "mysa", @@ -319,6 +352,10 @@ status: if test.duration != 0 { cmd.Flags().Set("duration", test.duration.String()) } + if test.enableNodeBindingFeature { + os.Setenv("KUBECTL_NODE_BOUND_TOKENS", "true") + defer os.Unsetenv("KUBECTL_NODE_BOUND_TOKENS") + } cmd.Run(cmd, []string{test.name}) if !reflect.DeepEqual(tokenRequest, test.expectTokenRequest) { diff --git a/test/integration/auth/svcaccttoken_test.go b/test/integration/auth/svcaccttoken_test.go index 912aba31979..7cb849494a5 100644 --- a/test/integration/auth/svcaccttoken_test.go +++ b/test/integration/auth/svcaccttoken_test.go @@ -41,15 +41,19 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/authentication/authenticator" apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount" + utilfeature "k8s.io/apiserver/pkg/util/feature" clientset "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/kubernetes/cmd/kube-apiserver/app/options" "k8s.io/kubernetes/pkg/apis/core" "k8s.io/kubernetes/pkg/controlplane" + "k8s.io/kubernetes/pkg/features" "k8s.io/kubernetes/pkg/serviceaccount" "k8s.io/kubernetes/test/integration/framework" "k8s.io/kubernetes/test/utils/ktesting" + "k8s.io/utils/ptr" ) const ( @@ -79,6 +83,12 @@ func TestServiceAccountTokenCreate(t *testing.T) { ctx, cancel := context.WithCancel(ctx) defer cancel() + // Enable the node token improvements feature gates prior to starting the apiserver, as the node getter is + // conditionally passed to the service account token generator based on feature enablement. + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenNodeBinding, true)() + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenPodNodeInfo, true)() + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenNodeBindingValidation, true)() + // Start the server var serverAddress string kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{ @@ -129,6 +139,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { Namespace: ns.Name, }, } + node = &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node", + }, + } pod = &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pod", @@ -139,6 +154,17 @@ func TestServiceAccountTokenCreate(t *testing.T) { Containers: []v1.Container{{Name: "test-container", Image: "nginx"}}, }, } + scheduledpod = &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: sa.Namespace, + }, + Spec: v1.PodSpec{ + ServiceAccountName: sa.Name, + NodeName: node.Name, + Containers: []v1.Container{{Name: "test-container", Image: "nginx"}}, + }, + } otherpod = &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "other-test-pod", @@ -155,7 +181,6 @@ func TestServiceAccountTokenCreate(t *testing.T) { Namespace: sa.Namespace, }, } - wrongUID = types.UID("wrong") noUID = types.UID("") ) @@ -220,6 +245,8 @@ func TestServiceAccountTokenCreate(t *testing.T) { }) t.Run("bound to service account and pod", func(t *testing.T) { + // Disable embedding pod's node info + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenPodNodeInfo, false)() treq := &authenticationv1.TokenRequest{ Spec: authenticationv1.TokenRequestSpec{ Audiences: []string{"api"}, @@ -276,6 +303,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { checkPayload(t, treq.Status.Token, "null", "kubernetes.io", "secret") checkPayload(t, treq.Status.Token, `"myns"`, "kubernetes.io", "namespace") checkPayload(t, treq.Status.Token, `"test-svcacct"`, "kubernetes.io", "serviceaccount", "name") + checkPayload(t, treq.Status.Token, "null", "kubernetes.io", "node") info := doTokenReview(t, cs, treq, false) if len(info.Extra) != 2 { @@ -291,6 +319,196 @@ func TestServiceAccountTokenCreate(t *testing.T) { doTokenReview(t, cs, treq, true) }) + testPodWithAssignedNode := func(node *v1.Node) func(t *testing.T) { + return func(t *testing.T) { + treq := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"api"}, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "v1", + Name: scheduledpod.Name, + }, + }, + } + + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token for nonexistant svcacct but got: %#v", resp) + } + warningHandler.assertEqual(t, nil) + sa, del := createDeleteSvcAcct(t, cs, sa) + defer del() + + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token bound to nonexistant pod but got: %#v", resp) + } + warningHandler.assertEqual(t, nil) + pod, delPod := createDeletePod(t, cs, scheduledpod) + defer delPod() + + if node != nil { + var delNode func() + node, delNode = createDeleteNode(t, cs, node) + defer delNode() + } + + // right uid + treq.Spec.BoundObjectRef.UID = pod.UID + warningHandler.clear() + if _, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err != nil { + t.Fatalf("err: %v", err) + } + warningHandler.assertEqual(t, nil) + // wrong uid + treq.Spec.BoundObjectRef.UID = wrongUID + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token bound to pod with wrong uid but got: %#v", resp) + } + warningHandler.assertEqual(t, nil) + // no uid + treq.Spec.BoundObjectRef.UID = noUID + warningHandler.clear() + treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("err: %v", err) + } + warningHandler.assertEqual(t, nil) + + checkPayload(t, treq.Status.Token, `"system:serviceaccount:myns:test-svcacct"`, "sub") + checkPayload(t, treq.Status.Token, `["api"]`, "aud") + checkPayload(t, treq.Status.Token, `"test-pod"`, "kubernetes.io", "pod", "name") + checkPayload(t, treq.Status.Token, "null", "kubernetes.io", "secret") + checkPayload(t, treq.Status.Token, `"myns"`, "kubernetes.io", "namespace") + checkPayload(t, treq.Status.Token, `"test-svcacct"`, "kubernetes.io", "serviceaccount", "name") + + expectedExtraValues := map[string]authenticationv1.ExtraValue{ + "authentication.kubernetes.io/pod-name": {pod.ObjectMeta.Name}, + "authentication.kubernetes.io/pod-uid": {string(pod.ObjectMeta.UID)}, + } + // If the NodeName is set at all, expect it to be included in the claims + if pod.Spec.NodeName != "" { + checkPayload(t, treq.Status.Token, fmt.Sprintf(`"%s"`, pod.Spec.NodeName), "kubernetes.io", "node", "name") + expectedExtraValues["authentication.kubernetes.io/node-name"] = authenticationv1.ExtraValue{pod.Spec.NodeName} + } + // If the node is non-nil, we expect the UID to be set too + if node != nil { + checkPayload(t, treq.Status.Token, fmt.Sprintf(`"%s"`, node.UID), "kubernetes.io", "node", "uid") + expectedExtraValues["authentication.kubernetes.io/node-uid"] = authenticationv1.ExtraValue{string(node.ObjectMeta.UID)} + } + + info := doTokenReview(t, cs, treq, false) + if len(info.Extra) != len(expectedExtraValues) { + t.Fatalf("expected Extra have length of %d but was length %d: %#v", len(expectedExtraValues), len(info.Extra), info.Extra) + } + if !reflect.DeepEqual(info.Extra, expectedExtraValues) { + t.Fatalf("unexpected Extra:\ngot:\t%#v\nwant:\t%#v", info.Extra, expectedExtraValues) + } + + delPod() + doTokenReview(t, cs, treq, true) + } + } + + t.Run("bound to service account and a pod with an assigned nodeName that does not exist", testPodWithAssignedNode(nil)) + t.Run("bound to service account and a pod with an assigned nodeName", testPodWithAssignedNode(node)) + + t.Run("fails to bind to a Node if the feature gate is disabled", func(t *testing.T) { + // Disable node binding + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountTokenNodeBinding, false)() + + // Create ServiceAccount and Node objects + sa, del := createDeleteSvcAcct(t, cs, sa) + defer del() + node, delNode := createDeleteNode(t, cs, node) + defer delNode() + + treq := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"api"}, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Node", + APIVersion: "v1", + Name: node.Name, + UID: node.UID, + }, + }, + } + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token with featuregate disabled but got: %#v", resp) + } else if err.Error() != "cannot bind token to a Node object as the \"ServiceAccountTokenNodeBinding\" feature-gate is disabled" { + t.Fatalf("expected error due to feature gate being disabled, but got: %s", err.Error()) + } + warningHandler.assertEqual(t, nil) + }) + + t.Run("bound to service account and node", func(t *testing.T) { + treq := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"api"}, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Node", + APIVersion: "v1", + Name: node.Name, + UID: node.UID, + }, + }, + } + + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token for nonexistant svcacct but got: %#v", resp) + } + warningHandler.assertEqual(t, nil) + sa, del := createDeleteSvcAcct(t, cs, sa) + defer del() + + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token bound to nonexistant node but got: %#v", resp) + } + warningHandler.assertEqual(t, nil) + node, delNode := createDeleteNode(t, cs, node) + defer delNode() + + // right uid + treq.Spec.BoundObjectRef.UID = node.UID + warningHandler.clear() + if _, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err != nil { + t.Fatalf("err: %v", err) + } + warningHandler.assertEqual(t, nil) + // wrong uid + treq.Spec.BoundObjectRef.UID = wrongUID + warningHandler.clear() + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}); err == nil { + t.Fatalf("expected err creating token bound to node with wrong uid but got: %#v", resp) + } + warningHandler.assertEqual(t, nil) + // no uid + treq.Spec.BoundObjectRef.UID = noUID + warningHandler.clear() + treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(ctx, sa.Name, treq, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("err: %v", err) + } + warningHandler.assertEqual(t, nil) + + checkPayload(t, treq.Status.Token, `"system:serviceaccount:myns:test-svcacct"`, "sub") + checkPayload(t, treq.Status.Token, `["api"]`, "aud") + checkPayload(t, treq.Status.Token, `null`, "kubernetes.io", "pod") + checkPayload(t, treq.Status.Token, `"test-node"`, "kubernetes.io", "node", "name") + checkPayload(t, treq.Status.Token, `"myns"`, "kubernetes.io", "namespace") + checkPayload(t, treq.Status.Token, `"test-svcacct"`, "kubernetes.io", "serviceaccount", "name") + + doTokenReview(t, cs, treq, false) + delNode() + doTokenReview(t, cs, treq, true) + }) + t.Run("bound to service account and secret", func(t *testing.T) { treq := &authenticationv1.TokenRequest{ Spec: authenticationv1.TokenRequestSpec{ @@ -410,7 +628,10 @@ func TestServiceAccountTokenCreate(t *testing.T) { coresa := core.ServiceAccount{ ObjectMeta: sa.ObjectMeta, } - _, pc := serviceaccount.Claims(coresa, nil, nil, 0, 0, nil) + _, pc, err := serviceaccount.Claims(coresa, nil, nil, nil, 0, 0, nil) + if err != nil { + t.Fatalf("err calling Claims: %v", err) + } tok, err := tokenGenerator.GenerateToken(sc, pc) if err != nil { t.Fatalf("err signing expired token: %v", err) @@ -985,7 +1206,9 @@ func createDeletePod(t *testing.T, cs clientset.Interface, pod *v1.Pod) (*v1.Pod return } done = true - if err := cs.CoreV1().Pods(pod.Namespace).Delete(context.TODO(), pod.Name, metav1.DeleteOptions{}); err != nil { + if err := cs.CoreV1().Pods(pod.Namespace).Delete(context.TODO(), pod.Name, metav1.DeleteOptions{ + GracePeriodSeconds: ptr.To(int64(0)), + }); err != nil { t.Fatalf("err: %v", err) } } @@ -1010,6 +1233,25 @@ func createDeleteSecret(t *testing.T, cs clientset.Interface, sec *v1.Secret) (* } } +func createDeleteNode(t *testing.T, cs clientset.Interface, node *v1.Node) (*v1.Node, func()) { + t.Helper() + node, err := cs.CoreV1().Nodes().Create(context.TODO(), node, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("err: %v", err) + } + done := false + return node, func() { + t.Helper() + if done { + return + } + done = true + if err := cs.CoreV1().Nodes().Delete(context.TODO(), node.Name, metav1.DeleteOptions{}); err != nil { + t.Fatalf("err: %v", err) + } + } +} + type recordingWarningHandler struct { warnings []string