KEP-4193: bound service account token improvements

This commit is contained in:
James Munnelly 2023-09-19 15:23:28 +01:00
parent 5cb83d1cd2
commit 76463e21d4
17 changed files with 831 additions and 80 deletions

View File

@ -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{})
}

View File

@ -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},

View File

@ -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()

View File

@ -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
}

View File

@ -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

View File

@ -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...),

View File

@ -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)
}

View File

@ -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},

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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]
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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),
}

View File

@ -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) {

View File

@ -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