mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-08-04 18:00:08 +00:00
KEP-4193: bound service account token improvements
This commit is contained in:
parent
5cb83d1cd2
commit
76463e21d4
@ -19,6 +19,7 @@ package serviceaccount
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"k8s.io/api/core/v1"
|
"k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
clientset "k8s.io/client-go/kubernetes"
|
clientset "k8s.io/client-go/kubernetes"
|
||||||
v1listers "k8s.io/client-go/listers/core/v1"
|
v1listers "k8s.io/client-go/listers/core/v1"
|
||||||
@ -31,14 +32,15 @@ type clientGetter struct {
|
|||||||
secretLister v1listers.SecretLister
|
secretLister v1listers.SecretLister
|
||||||
serviceAccountLister v1listers.ServiceAccountLister
|
serviceAccountLister v1listers.ServiceAccountLister
|
||||||
podLister v1listers.PodLister
|
podLister v1listers.PodLister
|
||||||
|
nodeLister v1listers.NodeLister
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGetterFromClient returns a ServiceAccountTokenGetter that
|
// 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 client should NOT authenticate using a service account token
|
||||||
// the returned getter will be used to retrieve, or recursion will result.
|
// 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 {
|
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}
|
return clientGetter{c, secretLister, serviceAccountLister, podLister, nodeLister}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c clientGetter) GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error) {
|
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{})
|
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{})
|
||||||
|
}
|
||||||
|
@ -736,6 +736,36 @@ const (
|
|||||||
// Decouples Taint Eviction Controller, performing taint-based Pod eviction, from Node Lifecycle Controller.
|
// Decouples Taint Eviction Controller, performing taint-based Pod eviction, from Node Lifecycle Controller.
|
||||||
SeparateTaintEvictionController featuregate.Feature = "SeparateTaintEvictionController"
|
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
|
// owner: @xuzhenglun
|
||||||
// kep: http://kep.k8s.io/3682
|
// kep: http://kep.k8s.io/3682
|
||||||
// alpha: v1.27
|
// alpha: v1.27
|
||||||
@ -1102,6 +1132,14 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
|
|||||||
|
|
||||||
SeparateTaintEvictionController: {Default: true, PreRelease: featuregate.Beta},
|
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
|
ServiceNodePortStaticSubrange: {Default: true, PreRelease: featuregate.GA, LockToDefault: true}, // GA in 1.29; remove in 1.31
|
||||||
|
|
||||||
SidecarContainers: {Default: false, PreRelease: featuregate.Alpha},
|
SidecarContainers: {Default: false, PreRelease: featuregate.Alpha},
|
||||||
|
@ -42,10 +42,12 @@ import (
|
|||||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
"k8s.io/client-go/informers"
|
"k8s.io/client-go/informers"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
|
v1listers "k8s.io/client-go/listers/core/v1"
|
||||||
cliflag "k8s.io/component-base/cli/flag"
|
cliflag "k8s.io/component-base/cli/flag"
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
openapicommon "k8s.io/kube-openapi/pkg/common"
|
openapicommon "k8s.io/kube-openapi/pkg/common"
|
||||||
serviceaccountcontroller "k8s.io/kubernetes/pkg/controller/serviceaccount"
|
serviceaccountcontroller "k8s.io/kubernetes/pkg/controller/serviceaccount"
|
||||||
|
"k8s.io/kubernetes/pkg/features"
|
||||||
kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
|
kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
|
||||||
authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes"
|
authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes"
|
||||||
"k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/bootstrap"
|
"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)
|
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(
|
authenticatorConfig.ServiceAccountTokenGetter = serviceaccountcontroller.NewGetterFromClient(
|
||||||
extclient,
|
extclient,
|
||||||
versionedInformer.Core().V1().Secrets().Lister(),
|
versionedInformer.Core().V1().Secrets().Lister(),
|
||||||
versionedInformer.Core().V1().ServiceAccounts().Lister(),
|
versionedInformer.Core().V1().ServiceAccounts().Lister(),
|
||||||
versionedInformer.Core().V1().Pods().Lister(),
|
versionedInformer.Core().V1().Pods().Lister(),
|
||||||
|
nodeLister,
|
||||||
)
|
)
|
||||||
authenticatorConfig.SecretsWriter = extclient.CoreV1()
|
authenticatorConfig.SecretsWriter = extclient.CoreV1()
|
||||||
|
|
||||||
|
@ -217,7 +217,12 @@ func (p *legacyProvider) NewRESTStorage(apiResourceConfigSource serverstorage.AP
|
|||||||
// potentially override the generic serviceaccount storage with one that supports pods
|
// potentially override the generic serviceaccount storage with one that supports pods
|
||||||
var serviceAccountStorage *serviceaccountstore.REST
|
var serviceAccountStorage *serviceaccountstore.REST
|
||||||
if p.ServiceAccountIssuer != nil {
|
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 {
|
if err != nil {
|
||||||
return genericapiserver.APIGroupInfo{}, err
|
return genericapiserver.APIGroupInfo{}, err
|
||||||
}
|
}
|
||||||
|
@ -95,9 +95,9 @@ func (c *GenericConfig) NewRESTStorage(apiResourceConfigSource serverstorage.API
|
|||||||
|
|
||||||
var serviceAccountStorage *serviceaccountstore.REST
|
var serviceAccountStorage *serviceaccountstore.REST
|
||||||
if c.ServiceAccountIssuer != nil {
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
return genericapiserver.APIGroupInfo{}, err
|
return genericapiserver.APIGroupInfo{}, err
|
||||||
|
@ -39,7 +39,7 @@ type REST struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewREST returns a RESTStorage object that will work against service accounts.
|
// 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{
|
store := &genericregistry.Store{
|
||||||
NewFunc: func() runtime.Object { return &api.ServiceAccount{} },
|
NewFunc: func() runtime.Object { return &api.ServiceAccount{} },
|
||||||
NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} },
|
NewListFunc: func() runtime.Object { return &api.ServiceAccountList{} },
|
||||||
@ -64,6 +64,7 @@ func NewREST(optsGetter generic.RESTOptionsGetter, issuer token.TokenGenerator,
|
|||||||
svcaccts: store,
|
svcaccts: store,
|
||||||
pods: podStorage,
|
pods: podStorage,
|
||||||
secrets: secretStorage,
|
secrets: secretStorage,
|
||||||
|
nodes: nodeStorage,
|
||||||
issuer: issuer,
|
issuer: issuer,
|
||||||
auds: auds,
|
auds: auds,
|
||||||
audsSet: sets.NewString(auds...),
|
audsSet: sets.NewString(auds...),
|
||||||
|
@ -38,7 +38,7 @@ func newStorage(t *testing.T) (*REST, *etcd3testing.EtcdTestServer) {
|
|||||||
DeleteCollectionWorkers: 1,
|
DeleteCollectionWorkers: 1,
|
||||||
ResourcePrefix: "serviceaccounts",
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error from REST storage: %v", err)
|
t.Fatalf("unexpected error from REST storage: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -29,13 +29,18 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"k8s.io/apimachinery/pkg/util/sets"
|
"k8s.io/apimachinery/pkg/util/sets"
|
||||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
"k8s.io/apiserver/pkg/audit"
|
||||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||||
|
"k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||||
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
"k8s.io/apiserver/pkg/warning"
|
"k8s.io/apiserver/pkg/warning"
|
||||||
|
"k8s.io/klog/v2"
|
||||||
authenticationapi "k8s.io/kubernetes/pkg/apis/authentication"
|
authenticationapi "k8s.io/kubernetes/pkg/apis/authentication"
|
||||||
authenticationvalidation "k8s.io/kubernetes/pkg/apis/authentication/validation"
|
authenticationvalidation "k8s.io/kubernetes/pkg/apis/authentication/validation"
|
||||||
api "k8s.io/kubernetes/pkg/apis/core"
|
api "k8s.io/kubernetes/pkg/apis/core"
|
||||||
|
"k8s.io/kubernetes/pkg/features"
|
||||||
token "k8s.io/kubernetes/pkg/serviceaccount"
|
token "k8s.io/kubernetes/pkg/serviceaccount"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -53,6 +58,7 @@ type TokenREST struct {
|
|||||||
svcaccts rest.Getter
|
svcaccts rest.Getter
|
||||||
pods rest.Getter
|
pods rest.Getter
|
||||||
secrets rest.Getter
|
secrets rest.Getter
|
||||||
|
nodes rest.Getter
|
||||||
issuer token.TokenGenerator
|
issuer token.TokenGenerator
|
||||||
auds authenticator.Audiences
|
auds authenticator.Audiences
|
||||||
audsSet sets.String
|
audsSet sets.String
|
||||||
@ -127,6 +133,7 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object,
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
pod *api.Pod
|
pod *api.Pod
|
||||||
|
node *api.Node
|
||||||
secret *api.Secret
|
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)
|
gvk := schema.FromAPIVersionAndKind(ref.APIVersion, ref.Kind)
|
||||||
switch {
|
switch {
|
||||||
case gvk.Group == "" && gvk.Kind == "Pod":
|
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{})
|
podObj, err := r.pods.Get(newCtx, ref.Name, &metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
return nil, errors.NewBadRequest(fmt.Sprintf("cannot bind token for serviceaccount %q to pod running with different serviceaccount name.", name))
|
||||||
}
|
}
|
||||||
uid = pod.UID
|
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":
|
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{})
|
secretObj, err := r.secrets.Get(newCtx, ref.Name, &metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -179,7 +219,10 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object,
|
|||||||
exp = token.ExpirationExtensionSeconds
|
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)
|
tokdata, err := r.issuer.GenerateToken(sc, pc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate token: %v", err)
|
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,
|
Token: tokdata,
|
||||||
ExpirationTimestamp: metav1.Time{Time: nowTime.Add(time.Duration(out.Spec.ExpirationSeconds) * time.Second)},
|
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
|
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
|
// 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 {
|
func newContext(ctx context.Context, resource, name, namespace string, gvk schema.GroupVersionKind) context.Context {
|
||||||
oldInfo, found := genericapirequest.RequestInfoFrom(ctx)
|
|
||||||
if !found {
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
newInfo := genericapirequest.RequestInfo{
|
newInfo := genericapirequest.RequestInfo{
|
||||||
IsResourceRequest: true,
|
IsResourceRequest: true,
|
||||||
Verb: "get",
|
Verb: "get",
|
||||||
Namespace: oldInfo.Namespace,
|
Namespace: namespace,
|
||||||
Resource: resource,
|
Resource: resource,
|
||||||
Name: name,
|
Name: name,
|
||||||
Parts: []string{resource, name},
|
Parts: []string{resource, name},
|
||||||
|
@ -22,12 +22,15 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"gopkg.in/square/go-jose.v2/jwt"
|
"gopkg.in/square/go-jose.v2/jwt"
|
||||||
"k8s.io/apiserver/pkg/audit"
|
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
|
|
||||||
|
"k8s.io/apiserver/pkg/audit"
|
||||||
apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
|
apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||||
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
"k8s.io/kubernetes/pkg/apis/core"
|
"k8s.io/kubernetes/pkg/apis/core"
|
||||||
|
"k8s.io/kubernetes/pkg/features"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -38,8 +41,12 @@ const (
|
|||||||
ExpirationExtensionSeconds = 24 * 365 * 60 * 60
|
ExpirationExtensionSeconds = 24 * 365 * 60 * 60
|
||||||
)
|
)
|
||||||
|
|
||||||
// time.Now stubbed out to allow testing
|
var (
|
||||||
var now = time.Now
|
// time.Now stubbed out to allow testing
|
||||||
|
now = time.Now
|
||||||
|
// uuid.New stubbed out to allow testing
|
||||||
|
newUUID = uuid.NewString
|
||||||
|
)
|
||||||
|
|
||||||
type privateClaims struct {
|
type privateClaims struct {
|
||||||
Kubernetes kubernetes `json:"kubernetes.io,omitempty"`
|
Kubernetes kubernetes `json:"kubernetes.io,omitempty"`
|
||||||
@ -50,6 +57,7 @@ type kubernetes struct {
|
|||||||
Svcacct ref `json:"serviceaccount,omitempty"`
|
Svcacct ref `json:"serviceaccount,omitempty"`
|
||||||
Pod *ref `json:"pod,omitempty"`
|
Pod *ref `json:"pod,omitempty"`
|
||||||
Secret *ref `json:"secret,omitempty"`
|
Secret *ref `json:"secret,omitempty"`
|
||||||
|
Node *ref `json:"node,omitempty"`
|
||||||
WarnAfter *jwt.NumericDate `json:"warnafter,omitempty"`
|
WarnAfter *jwt.NumericDate `json:"warnafter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +66,7 @@ type ref struct {
|
|||||||
UID string `json:"uid,omitempty"`
|
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()
|
now := now()
|
||||||
sc := &jwt.Claims{
|
sc := &jwt.Claims{
|
||||||
Subject: apiserverserviceaccount.MakeUsername(sa.Namespace, sa.Name),
|
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),
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
Expiry: jwt.NewNumericDate(now.Add(time.Duration(expirationSeconds) * time.Second)),
|
Expiry: jwt.NewNumericDate(now.Add(time.Duration(expirationSeconds) * time.Second)),
|
||||||
}
|
}
|
||||||
|
if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountTokenJTI) {
|
||||||
|
sc.ID = newUUID()
|
||||||
|
}
|
||||||
pc := &privateClaims{
|
pc := &privateClaims{
|
||||||
Kubernetes: kubernetes{
|
Kubernetes: kubernetes{
|
||||||
Namespace: sa.Namespace,
|
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 {
|
switch {
|
||||||
case pod != nil:
|
case pod != nil:
|
||||||
pc.Kubernetes.Pod = &ref{
|
pc.Kubernetes.Pod = &ref{
|
||||||
Name: pod.Name,
|
Name: pod.Name,
|
||||||
UID: string(pod.UID),
|
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:
|
case secret != nil:
|
||||||
pc.Kubernetes.Secret = &ref{
|
pc.Kubernetes.Secret = &ref{
|
||||||
Name: secret.Name,
|
Name: secret.Name,
|
||||||
UID: string(secret.UID),
|
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 {
|
if warnafter != 0 {
|
||||||
pc.Kubernetes.WarnAfter = jwt.NewNumericDate(now.Add(time.Duration(warnafter) * time.Second))
|
pc.Kubernetes.WarnAfter = jwt.NewNumericDate(now.Add(time.Duration(warnafter) * time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sc, pc
|
return sc, pc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewValidator(getter ServiceAccountTokenGetter) Validator {
|
func NewValidator(getter ServiceAccountTokenGetter) Validator {
|
||||||
@ -146,6 +178,7 @@ func (v *validator) Validate(ctx context.Context, _ string, public *jwt.Claims,
|
|||||||
namespace := private.Kubernetes.Namespace
|
namespace := private.Kubernetes.Namespace
|
||||||
saref := private.Kubernetes.Svcacct
|
saref := private.Kubernetes.Svcacct
|
||||||
podref := private.Kubernetes.Pod
|
podref := private.Kubernetes.Pod
|
||||||
|
noderef := private.Kubernetes.Node
|
||||||
secref := private.Kubernetes.Secret
|
secref := private.Kubernetes.Secret
|
||||||
// Make sure service account still exists (name and UID)
|
// Make sure service account still exists (name and UID)
|
||||||
serviceAccount, err := v.getter.GetServiceAccount(namespace, saref.Name)
|
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)
|
klog.V(4).Infof("Could not retrieve service account %s/%s: %v", namespace, saref.Name, err)
|
||||||
return nil, 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 {
|
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)
|
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)
|
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 {
|
if secref != nil {
|
||||||
// Make sure token hasn't been invalidated by deletion of the secret
|
// 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)
|
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")
|
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) {
|
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)
|
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)
|
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
|
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)
|
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")
|
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) {
|
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)
|
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)
|
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
|
podName = podref.Name
|
||||||
podUID = podref.UID
|
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.
|
// Check special 'warnafter' field for projected service account token transition.
|
||||||
warnafter := private.Kubernetes.WarnAfter
|
warnafter := private.Kubernetes.WarnAfter
|
||||||
if warnafter != nil && *warnafter != 0 {
|
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{
|
return &apiserverserviceaccount.ServiceAccountInfo{
|
||||||
Namespace: private.Kubernetes.Namespace,
|
Namespace: private.Kubernetes.Namespace,
|
||||||
Name: private.Kubernetes.Svcacct.Name,
|
Name: private.Kubernetes.Svcacct.Name,
|
||||||
UID: private.Kubernetes.Svcacct.UID,
|
UID: private.Kubernetes.Svcacct.UID,
|
||||||
PodName: podName,
|
PodName: podName,
|
||||||
PodUID: podUID,
|
PodUID: podUID,
|
||||||
|
NodeName: nodeName,
|
||||||
|
NodeUID: nodeUID,
|
||||||
|
CredentialID: apiserverserviceaccount.CredentialIDForJTI(jti),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,10 @@ import (
|
|||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"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/apis/core"
|
||||||
|
"k8s.io/kubernetes/pkg/features"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -37,6 +40,11 @@ func init() {
|
|||||||
// epoch time: 1514764800
|
// epoch time: 1514764800
|
||||||
return time.Date(2018, time.January, 1, 0, 0, 0, 0, time.UTC)
|
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) {
|
func TestClaims(t *testing.T) {
|
||||||
@ -61,17 +69,27 @@ func TestClaims(t *testing.T) {
|
|||||||
UID: "mysecret-uid",
|
UID: "mysecret-uid",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
node := &core.Node{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "mynode",
|
||||||
|
UID: "mynode-uid",
|
||||||
|
},
|
||||||
|
}
|
||||||
cs := []struct {
|
cs := []struct {
|
||||||
// input
|
// input
|
||||||
sa core.ServiceAccount
|
sa core.ServiceAccount
|
||||||
pod *core.Pod
|
pod *core.Pod
|
||||||
sec *core.Secret
|
sec *core.Secret
|
||||||
|
node *core.Node
|
||||||
exp int64
|
exp int64
|
||||||
warnafter int64
|
warnafter int64
|
||||||
aud []string
|
aud []string
|
||||||
|
err string
|
||||||
// desired
|
// desired
|
||||||
sc *jwt.Claims
|
sc *jwt.Claims
|
||||||
pc *privateClaims
|
pc *privateClaims
|
||||||
|
|
||||||
|
featureJTI, featurePodNodeInfo, featureNodeBinding bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
// pod and secret
|
// pod and secret
|
||||||
@ -82,20 +100,7 @@ func TestClaims(t *testing.T) {
|
|||||||
exp: 0,
|
exp: 0,
|
||||||
// nil audience
|
// nil audience
|
||||||
aud: nil,
|
aud: nil,
|
||||||
|
err: "internal error, token can only be bound to one object type",
|
||||||
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"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// pod
|
// pod
|
||||||
@ -167,7 +172,6 @@ func TestClaims(t *testing.T) {
|
|||||||
// warn after provided
|
// warn after provided
|
||||||
sa: sa,
|
sa: sa,
|
||||||
pod: pod,
|
pod: pod,
|
||||||
sec: sec,
|
|
||||||
exp: 60 * 60 * 24,
|
exp: 60 * 60 * 24,
|
||||||
warnafter: 60 * 60,
|
warnafter: 60 * 60,
|
||||||
// nil audience
|
// 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 {
|
for i, c := range cs {
|
||||||
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
|
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
|
||||||
@ -202,7 +341,18 @@ func TestClaims(t *testing.T) {
|
|||||||
return string(b)
|
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) {
|
if spew(sc) != spew(c.sc) {
|
||||||
t.Errorf("standard claims differed\n\tsaw:\t%s\n\twant:\t%s", 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
|
expiry jwt.NumericDate
|
||||||
notBefore jwt.NumericDate
|
notBefore jwt.NumericDate
|
||||||
expectErr string
|
expectErr string
|
||||||
|
|
||||||
|
featureNodeBindingValidation bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatePrivateClaims(t *testing.T) {
|
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"}}
|
serviceAccount = &v1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "saname", Namespace: "ns", UID: "sauid"}}
|
||||||
secret = &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secretname", Namespace: "ns", UID: "secretuid"}}
|
secret = &v1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secretname", Namespace: "ns", UID: "secretuid"}}
|
||||||
pod = &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "podname", Namespace: "ns", UID: "poduid"}}
|
pod = &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "podname", Namespace: "ns", UID: "poduid"}}
|
||||||
|
node = &v1.Node{ObjectMeta: metav1.ObjectMeta{Name: "nodename", UID: "nodeuid"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
deletionTestCases := []deletionTestCase{
|
deletionTestCases := []deletionTestCase{
|
||||||
@ -268,57 +421,64 @@ func TestValidatePrivateClaims(t *testing.T) {
|
|||||||
testcases := []claimTestCase{
|
testcases := []claimTestCase{
|
||||||
{
|
{
|
||||||
name: "good",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}},
|
||||||
expectErr: "",
|
expectErr: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "expired",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}},
|
||||||
expiry: *jwt.NewNumericDate(now().Add(-1_000 * time.Hour)),
|
expiry: *jwt.NewNumericDate(now().Add(-1_000 * time.Hour)),
|
||||||
expectErr: "service account token has expired",
|
expectErr: "service account token has expired",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "not yet valid",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}},
|
||||||
notBefore: *jwt.NewNumericDate(now().Add(1_000 * time.Hour)),
|
notBefore: *jwt.NewNumericDate(now().Add(1_000 * time.Hour)),
|
||||||
expectErr: "service account token is not valid yet",
|
expectErr: "service account token is not valid yet",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing serviceaccount",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}},
|
||||||
expectErr: `serviceaccounts "saname" not found`,
|
expectErr: `serviceaccounts "saname" not found`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing secret",
|
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"}},
|
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",
|
expectErr: "service account token has been invalidated",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing pod",
|
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"}},
|
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",
|
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",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauidold"}, Namespace: "ns"}},
|
||||||
expectErr: "service account UID (sauid) does not match claim (sauidold)",
|
expectErr: "service account UID (sauid) does not match claim (sauidold)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "different uid secret",
|
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"}},
|
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)",
|
expectErr: "secret UID (secretuid) does not match service account secret ref claim (secretuidold)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "different uid pod",
|
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"}},
|
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)",
|
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()
|
deletedServiceAccount = serviceAccount.DeepCopy()
|
||||||
deletedPod = pod.DeepCopy()
|
deletedPod = pod.DeepCopy()
|
||||||
deletedSecret = secret.DeepCopy()
|
deletedSecret = secret.DeepCopy()
|
||||||
|
deletedNode = node.DeepCopy()
|
||||||
)
|
)
|
||||||
deletedServiceAccount.DeletionTimestamp = deletionTestCase.time
|
deletedServiceAccount.DeletionTimestamp = deletionTestCase.time
|
||||||
deletedPod.DeletionTimestamp = deletionTestCase.time
|
deletedPod.DeletionTimestamp = deletionTestCase.time
|
||||||
deletedSecret.DeletionTimestamp = deletionTestCase.time
|
deletedSecret.DeletionTimestamp = deletionTestCase.time
|
||||||
|
deletedNode.DeletionTimestamp = deletionTestCase.time
|
||||||
|
|
||||||
var saDeletedErr, deletedErr string
|
var saDeletedErr, deletedErr string
|
||||||
if deletionTestCase.expectErr {
|
if deletionTestCase.expectErr {
|
||||||
@ -343,32 +505,42 @@ func TestValidatePrivateClaims(t *testing.T) {
|
|||||||
testcases = append(testcases,
|
testcases = append(testcases,
|
||||||
claimTestCase{
|
claimTestCase{
|
||||||
name: deletionTestCase.name + " serviceaccount",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Namespace: "ns"}},
|
||||||
expectErr: saDeletedErr,
|
expectErr: saDeletedErr,
|
||||||
},
|
},
|
||||||
claimTestCase{
|
claimTestCase{
|
||||||
name: deletionTestCase.name + " secret",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Secret: &ref{Name: "secretname", UID: "secretuid"}, Namespace: "ns"}},
|
||||||
expectErr: deletedErr,
|
expectErr: deletedErr,
|
||||||
},
|
},
|
||||||
claimTestCase{
|
claimTestCase{
|
||||||
name: deletionTestCase.name + " pod",
|
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"}},
|
private: &privateClaims{Kubernetes: kubernetes{Svcacct: ref{Name: "saname", UID: "sauid"}, Pod: &ref{Name: "podname", UID: "poduid"}, Namespace: "ns"}},
|
||||||
expectErr: deletedErr,
|
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 {
|
for _, tc := range testcases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
v := &validator{tc.getter}
|
v := &validator{getter: tc.getter}
|
||||||
expiry := jwt.NumericDate(nowUnix)
|
expiry := jwt.NumericDate(nowUnix)
|
||||||
if tc.expiry != 0 {
|
if tc.expiry != 0 {
|
||||||
expiry = tc.expiry
|
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)
|
_, err := v.Validate(context.Background(), "", &jwt.Claims{Expiry: &expiry, NotBefore: &tc.notBefore}, tc.private)
|
||||||
if len(tc.expectErr) > 0 {
|
if len(tc.expectErr) > 0 {
|
||||||
if errStr := errString(err); tc.expectErr != errStr {
|
if errStr := errString(err); tc.expectErr != errStr {
|
||||||
@ -393,6 +565,7 @@ type fakeGetter struct {
|
|||||||
serviceAccount *v1.ServiceAccount
|
serviceAccount *v1.ServiceAccount
|
||||||
secret *v1.Secret
|
secret *v1.Secret
|
||||||
pod *v1.Pod
|
pod *v1.Pod
|
||||||
|
node *v1.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f fakeGetter) GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error) {
|
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
|
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
|
||||||
|
}
|
||||||
|
@ -43,6 +43,7 @@ type ServiceAccountTokenGetter interface {
|
|||||||
GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error)
|
GetServiceAccount(namespace, name string) (*v1.ServiceAccount, error)
|
||||||
GetPod(namespace, name string) (*v1.Pod, error)
|
GetPod(namespace, name string) (*v1.Pod, error)
|
||||||
GetSecret(namespace, name string) (*v1.Secret, error)
|
GetSecret(namespace, name string) (*v1.Secret, error)
|
||||||
|
GetNode(name string) (*v1.Node, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenGenerator interface {
|
type TokenGenerator interface {
|
||||||
|
@ -371,6 +371,9 @@ func TestTokenGenerateAndValidate(t *testing.T) {
|
|||||||
v1listers.NewPodLister(newIndexer(func(namespace, name string) (interface{}, error) {
|
v1listers.NewPodLister(newIndexer(func(namespace, name string) (interface{}, error) {
|
||||||
return tc.Client.CoreV1().Pods(namespace).Get(context.TODO(), name, metav1.GetOptions{})
|
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
|
var secretsWriter typedv1core.SecretsGetter
|
||||||
if tc.Client != nil {
|
if tc.Client != nil {
|
||||||
@ -443,6 +446,11 @@ func (f *fakeIndexer) GetByKey(key string) (interface{}, bool, error) {
|
|||||||
parts := strings.SplitN(key, "/", 2)
|
parts := strings.SplitN(key, "/", 2)
|
||||||
namespace := parts[0]
|
namespace := parts[0]
|
||||||
name := ""
|
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 {
|
if len(parts) == 2 {
|
||||||
name = parts[1]
|
name = parts[1]
|
||||||
}
|
}
|
||||||
|
@ -36,12 +36,21 @@ const (
|
|||||||
ServiceAccountUsernameSeparator = ":"
|
ServiceAccountUsernameSeparator = ":"
|
||||||
ServiceAccountGroupPrefix = "system:serviceaccounts:"
|
ServiceAccountGroupPrefix = "system:serviceaccounts:"
|
||||||
AllServiceAccountsGroup = "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
|
// PodNameKey is the key used in a user's "extra" to specify the pod name of
|
||||||
// the authenticating request.
|
// the authenticating request.
|
||||||
PodNameKey = "authentication.kubernetes.io/pod-name"
|
PodNameKey = "authentication.kubernetes.io/pod-name"
|
||||||
// PodUIDKey is the key used in a user's "extra" to specify the pod UID of
|
// PodUIDKey is the key used in a user's "extra" to specify the pod UID of
|
||||||
// the authenticating request.
|
// the authenticating request.
|
||||||
PodUIDKey = "authentication.kubernetes.io/pod-uid"
|
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.
|
// 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 {
|
type ServiceAccountInfo struct {
|
||||||
Name, Namespace, UID string
|
Name, Namespace, UID string
|
||||||
PodName, PodUID string
|
PodName, PodUID string
|
||||||
|
CredentialID string
|
||||||
|
NodeName, NodeUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sa *ServiceAccountInfo) UserInfo() user.Info {
|
func (sa *ServiceAccountInfo) UserInfo() user.Info {
|
||||||
@ -127,15 +138,43 @@ func (sa *ServiceAccountInfo) UserInfo() user.Info {
|
|||||||
UID: sa.UID,
|
UID: sa.UID,
|
||||||
Groups: MakeGroupNames(sa.Namespace),
|
Groups: MakeGroupNames(sa.Namespace),
|
||||||
}
|
}
|
||||||
|
|
||||||
if sa.PodName != "" && sa.PodUID != "" {
|
if sa.PodName != "" && sa.PodUID != "" {
|
||||||
info.Extra = map[string][]string{
|
if info.Extra == nil {
|
||||||
PodNameKey: {sa.PodName},
|
info.Extra = make(map[string][]string)
|
||||||
PodUIDKey: {sa.PodUID},
|
}
|
||||||
|
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
|
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
|
// IsServiceAccountToken returns true if the secret is a valid api token for the service account
|
||||||
func IsServiceAccountToken(secret *v1.Secret, sa *v1.ServiceAccount) bool {
|
func IsServiceAccountToken(secret *v1.Secret, sa *v1.ServiceAccount) bool {
|
||||||
if secret.Type != v1.SecretTypeServiceAccountToken {
|
if secret.Type != v1.SecretTypeServiceAccountToken {
|
||||||
|
@ -17,12 +17,70 @@ limitations under the License.
|
|||||||
package serviceaccount
|
package serviceaccount
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/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) {
|
func TestMakeUsername(t *testing.T) {
|
||||||
|
|
||||||
testCases := map[string]struct {
|
testCases := map[string]struct {
|
||||||
|
@ -19,6 +19,7 @@ package create
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -96,12 +97,18 @@ var (
|
|||||||
# Request a token bound to an instance of a Secret object with a specific UID
|
# 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
|
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",
|
"Pod": "v1",
|
||||||
"Secret": "v1",
|
"Secret": "v1",
|
||||||
}
|
}
|
||||||
)
|
if os.Getenv("KUBECTL_NODE_BOUND_TOKENS") == "true" {
|
||||||
|
kinds["Node"] = "v1"
|
||||||
|
}
|
||||||
|
return kinds
|
||||||
|
}
|
||||||
|
|
||||||
func NewTokenOpts(ioStreams genericiooptions.IOStreams) *TokenOptions {
|
func NewTokenOpts(ioStreams genericiooptions.IOStreams) *TokenOptions {
|
||||||
return &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().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. "+
|
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.")
|
"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. "+
|
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. "+
|
"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")
|
return fmt.Errorf("--bound-object-uid can only be set if --bound-object-kind is provided")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, ok := boundObjectKindToAPIVersion[o.BoundObjectKind]; !ok {
|
if _, ok := boundObjectKindToAPIVersions()[o.BoundObjectKind]; !ok {
|
||||||
return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", "))
|
return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersions()).List(), ", "))
|
||||||
}
|
}
|
||||||
if len(o.BoundObjectName) == 0 {
|
if len(o.BoundObjectName) == 0 {
|
||||||
return fmt.Errorf("--bound-object-name is required if --bound-object-kind is provided")
|
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 {
|
if len(o.BoundObjectKind) > 0 {
|
||||||
request.Spec.BoundObjectRef = &authenticationv1.BoundObjectReference{
|
request.Spec.BoundObjectRef = &authenticationv1.BoundObjectReference{
|
||||||
Kind: o.BoundObjectKind,
|
Kind: o.BoundObjectKind,
|
||||||
APIVersion: boundObjectKindToAPIVersion[o.BoundObjectKind],
|
APIVersion: boundObjectKindToAPIVersions()[o.BoundObjectKind],
|
||||||
Name: o.BoundObjectName,
|
Name: o.BoundObjectName,
|
||||||
UID: types.UID(o.BoundObjectUID),
|
UID: types.UID(o.BoundObjectUID),
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -53,6 +54,8 @@ func TestCreateToken(t *testing.T) {
|
|||||||
audiences []string
|
audiences []string
|
||||||
duration time.Duration
|
duration time.Duration
|
||||||
|
|
||||||
|
enableNodeBindingFeature bool
|
||||||
|
|
||||||
serverResponseToken string
|
serverResponseToken string
|
||||||
serverResponseError string
|
serverResponseError string
|
||||||
|
|
||||||
@ -117,6 +120,13 @@ status:
|
|||||||
boundObjectKind: "Foo",
|
boundObjectKind: "Foo",
|
||||||
expectStderr: `error: supported --bound-object-kind values are Pod, Secret`,
|
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",
|
test: "missing bound object name",
|
||||||
name: "mysa",
|
name: "mysa",
|
||||||
@ -158,7 +168,30 @@ status:
|
|||||||
serverResponseToken: "abc",
|
serverResponseToken: "abc",
|
||||||
expectStdout: "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",
|
test: "invalid audience",
|
||||||
name: "mysa",
|
name: "mysa",
|
||||||
@ -319,6 +352,10 @@ status:
|
|||||||
if test.duration != 0 {
|
if test.duration != 0 {
|
||||||
cmd.Flags().Set("duration", test.duration.String())
|
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})
|
cmd.Run(cmd, []string{test.name})
|
||||||
|
|
||||||
if !reflect.DeepEqual(tokenRequest, test.expectTokenRequest) {
|
if !reflect.DeepEqual(tokenRequest, test.expectTokenRequest) {
|
||||||
|
@ -41,15 +41,19 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||||
apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
|
apiserverserviceaccount "k8s.io/apiserver/pkg/authentication/serviceaccount"
|
||||||
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||||
clientset "k8s.io/client-go/kubernetes"
|
clientset "k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/kubernetes/scheme"
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||||
"k8s.io/kubernetes/cmd/kube-apiserver/app/options"
|
"k8s.io/kubernetes/cmd/kube-apiserver/app/options"
|
||||||
"k8s.io/kubernetes/pkg/apis/core"
|
"k8s.io/kubernetes/pkg/apis/core"
|
||||||
"k8s.io/kubernetes/pkg/controlplane"
|
"k8s.io/kubernetes/pkg/controlplane"
|
||||||
|
"k8s.io/kubernetes/pkg/features"
|
||||||
"k8s.io/kubernetes/pkg/serviceaccount"
|
"k8s.io/kubernetes/pkg/serviceaccount"
|
||||||
"k8s.io/kubernetes/test/integration/framework"
|
"k8s.io/kubernetes/test/integration/framework"
|
||||||
"k8s.io/kubernetes/test/utils/ktesting"
|
"k8s.io/kubernetes/test/utils/ktesting"
|
||||||
|
"k8s.io/utils/ptr"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -79,6 +83,12 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
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
|
// Start the server
|
||||||
var serverAddress string
|
var serverAddress string
|
||||||
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{
|
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(ctx, t, framework.TestServerSetup{
|
||||||
@ -129,6 +139,11 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
Namespace: ns.Name,
|
Namespace: ns.Name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
node = &v1.Node{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-node",
|
||||||
|
},
|
||||||
|
}
|
||||||
pod = &v1.Pod{
|
pod = &v1.Pod{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test-pod",
|
Name: "test-pod",
|
||||||
@ -139,6 +154,17 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
Containers: []v1.Container{{Name: "test-container", Image: "nginx"}},
|
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{
|
otherpod = &v1.Pod{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "other-test-pod",
|
Name: "other-test-pod",
|
||||||
@ -155,7 +181,6 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
Namespace: sa.Namespace,
|
Namespace: sa.Namespace,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
wrongUID = types.UID("wrong")
|
wrongUID = types.UID("wrong")
|
||||||
noUID = types.UID("")
|
noUID = types.UID("")
|
||||||
)
|
)
|
||||||
@ -220,6 +245,8 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("bound to service account and pod", func(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{
|
treq := &authenticationv1.TokenRequest{
|
||||||
Spec: authenticationv1.TokenRequestSpec{
|
Spec: authenticationv1.TokenRequestSpec{
|
||||||
Audiences: []string{"api"},
|
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, "null", "kubernetes.io", "secret")
|
||||||
checkPayload(t, treq.Status.Token, `"myns"`, "kubernetes.io", "namespace")
|
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, `"test-svcacct"`, "kubernetes.io", "serviceaccount", "name")
|
||||||
|
checkPayload(t, treq.Status.Token, "null", "kubernetes.io", "node")
|
||||||
|
|
||||||
info := doTokenReview(t, cs, treq, false)
|
info := doTokenReview(t, cs, treq, false)
|
||||||
if len(info.Extra) != 2 {
|
if len(info.Extra) != 2 {
|
||||||
@ -291,6 +319,196 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
doTokenReview(t, cs, treq, true)
|
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) {
|
t.Run("bound to service account and secret", func(t *testing.T) {
|
||||||
treq := &authenticationv1.TokenRequest{
|
treq := &authenticationv1.TokenRequest{
|
||||||
Spec: authenticationv1.TokenRequestSpec{
|
Spec: authenticationv1.TokenRequestSpec{
|
||||||
@ -410,7 +628,10 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
|||||||
coresa := core.ServiceAccount{
|
coresa := core.ServiceAccount{
|
||||||
ObjectMeta: sa.ObjectMeta,
|
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)
|
tok, err := tokenGenerator.GenerateToken(sc, pc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("err signing expired token: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
done = true
|
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)
|
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 {
|
type recordingWarningHandler struct {
|
||||||
warnings []string
|
warnings []string
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user