From 19d71bb5d5aa66f1d2fda2a2beb5d5238ef33541 Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Fri, 4 Feb 2022 13:45:18 -0500 Subject: [PATCH 1/3] Validate and populate metadata fields in token request --- .../core/serviceaccount/storage/token.go | 76 +++++++++---- test/integration/auth/svcaccttoken_test.go | 101 +++++++++++++++++- 2 files changed, 156 insertions(+), 21 deletions(-) diff --git a/pkg/registry/core/serviceaccount/storage/token.go b/pkg/registry/core/serviceaccount/storage/token.go index 19d6c55a2ccd7..8ba30ed8446c6 100644 --- a/pkg/registry/core/serviceaccount/storage/token.go +++ b/pkg/registry/core/serviceaccount/storage/token.go @@ -28,9 +28,11 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/authentication/authenticator" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/warning" authenticationapi "k8s.io/kubernetes/pkg/apis/authentication" authenticationvalidation "k8s.io/kubernetes/pkg/apis/authentication/validation" api "k8s.io/kubernetes/pkg/apis/core" @@ -62,30 +64,67 @@ var gvk = schema.GroupVersionKind{ } func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { - if createValidation != nil { - if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { - return nil, err - } - } + req := obj.(*authenticationapi.TokenRequest) - out := obj.(*authenticationapi.TokenRequest) + // Get the namespace from the context (populated from the URL). + namespace, ok := genericapirequest.NamespaceFrom(ctx) + if !ok { + return nil, errors.NewBadRequest("namespace is required") + } - if errs := authenticationvalidation.ValidateTokenRequest(out); len(errs) != 0 { - return nil, errors.NewInvalid(gvk.GroupKind(), "", errs) + // require name/namespace in the body to match URL if specified + if len(req.Name) > 0 && req.Name != name { + errs := field.ErrorList{field.Invalid(field.NewPath("metadata").Child("name"), req.Name, "must match the service account name if specified")} + return nil, errors.NewInvalid(gvk.GroupKind(), name, errs) + } + if len(req.Namespace) > 0 && req.Namespace != namespace { + errs := field.ErrorList{field.Invalid(field.NewPath("metadata").Child("namespace"), req.Namespace, "must match the service account namespace if specified")} + return nil, errors.NewInvalid(gvk.GroupKind(), name, errs) } + // Lookup service account svcacctObj, err := r.svcaccts.Get(ctx, name, &metav1.GetOptions{}) if err != nil { return nil, err } svcacct := svcacctObj.(*api.ServiceAccount) + // Default unset spec audiences to API server audiences based on server config + if len(req.Spec.Audiences) == 0 { + req.Spec.Audiences = r.auds + } + // Populate metadata fields if not set + if len(req.Name) == 0 { + req.Name = svcacct.Name + } + if len(req.Namespace) == 0 { + req.Namespace = svcacct.Namespace + } + + // Save current time before building the token, to make sure the expiration + // returned in TokenRequestStatus would be <= the exp field in token. + nowTime := time.Now() + req.CreationTimestamp = metav1.NewTime(nowTime) + + // Clear status + req.Status = authenticationapi.TokenRequestStatus{} + + // call static validation, then validating admission + if errs := authenticationvalidation.ValidateTokenRequest(req); len(errs) != 0 { + return nil, errors.NewInvalid(gvk.GroupKind(), "", errs) + } + if createValidation != nil { + if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { + return nil, err + } + } + var ( pod *api.Pod secret *api.Secret ) - if ref := out.Spec.BoundObjectRef; ref != nil { + if ref := req.Spec.BoundObjectRef; ref != nil { var uid types.UID gvk := schema.FromAPIVersionAndKind(ref.APIVersion, ref.Kind) @@ -116,13 +155,11 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, return nil, errors.NewConflict(schema.GroupResource{Group: gvk.Group, Resource: gvk.Kind}, ref.Name, fmt.Errorf("the UID in the bound object reference (%s) does not match the UID in record. The object might have been deleted and then recreated", ref.UID)) } } - if len(out.Spec.Audiences) == 0 { - out.Spec.Audiences = r.auds - } - if r.maxExpirationSeconds > 0 && out.Spec.ExpirationSeconds > r.maxExpirationSeconds { + if r.maxExpirationSeconds > 0 && req.Spec.ExpirationSeconds > r.maxExpirationSeconds { //only positive value is valid - out.Spec.ExpirationSeconds = r.maxExpirationSeconds + warning.AddWarning(ctx, "", fmt.Sprintf("requested expiration of %d seconds shortened to %d seconds", req.Spec.ExpirationSeconds, r.maxExpirationSeconds)) + req.Spec.ExpirationSeconds = r.maxExpirationSeconds } // Tweak expiration for safe transition of projected service account token. @@ -130,21 +167,20 @@ func (r *TokenREST) Create(ctx context.Context, name string, obj runtime.Object, // Fail after hard-coded extended expiration time. // Only perform the extension when token is pod-bound. var warnAfter int64 - exp := out.Spec.ExpirationSeconds - if r.extendExpiration && pod != nil && out.Spec.ExpirationSeconds == token.WarnOnlyBoundTokenExpirationSeconds && r.isKubeAudiences(out.Spec.Audiences) { + exp := req.Spec.ExpirationSeconds + if r.extendExpiration && pod != nil && req.Spec.ExpirationSeconds == token.WarnOnlyBoundTokenExpirationSeconds && r.isKubeAudiences(req.Spec.Audiences) { warnAfter = exp exp = token.ExpirationExtensionSeconds } - // Save current time before building the token, to make sure the expiration - // returned in TokenRequestStatus would be earlier than exp field in token. - nowTime := time.Now() - sc, pc := token.Claims(*svcacct, pod, secret, exp, warnAfter, out.Spec.Audiences) + sc, pc := token.Claims(*svcacct, pod, secret, exp, warnAfter, req.Spec.Audiences) tokdata, err := r.issuer.GenerateToken(sc, pc) if err != nil { return nil, fmt.Errorf("failed to generate token: %v", err) } + // populate status + out := req.DeepCopy() out.Status = authenticationapi.TokenRequestStatus{ Token: tokdata, ExpirationTimestamp: metav1.Time{Time: nowTime.Add(time.Duration(out.Spec.ExpirationSeconds) * time.Second)}, diff --git a/test/integration/auth/svcaccttoken_test.go b/test/integration/auth/svcaccttoken_test.go index baaccebaf0e24..85bfc0fc46db2 100644 --- a/test/integration/auth/svcaccttoken_test.go +++ b/test/integration/auth/svcaccttoken_test.go @@ -29,6 +29,7 @@ import ( "reflect" "strconv" "strings" + "sync" "testing" "time" @@ -126,7 +127,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { instanceConfig, _, closeFn := framework.RunAnAPIServer(controlPlaneConfig) defer closeFn() - cs, err := clientset.NewForConfig(instanceConfig.GenericAPIServer.LoopbackClientConfig) + warningHandler := &recordingWarningHandler{} + + configWithWarningHandler := rest.CopyConfig(instanceConfig.GenericAPIServer.LoopbackClientConfig) + configWithWarningHandler.WarningHandler = warningHandler + cs, err := clientset.NewForConfig(configWithWarningHandler) if err != nil { t.Fatalf("err: %v", err) } @@ -182,16 +187,42 @@ func TestServiceAccountTokenCreate(t *testing.T) { }, } + warningHandler.clear() if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), 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, delSvcAcct := createDeleteSvcAcct(t, cs, sa) defer delSvcAcct() + treqWithBadName := treq.DeepCopy() + treqWithBadName.Name = "invalid-name" + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treqWithBadName, metav1.CreateOptions{}); err == nil || !strings.Contains(err.Error(), "must match the service account name") { + t.Fatalf("expected err creating token with mismatched name but got: %#v", resp) + } + + treqWithBadNamespace := treq.DeepCopy() + treqWithBadNamespace.Namespace = "invalid-namespace" + if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treqWithBadNamespace, metav1.CreateOptions{}); err == nil || !strings.Contains(err.Error(), "must match the service account namespace") { + t.Fatalf("expected err creating token with mismatched namespace but got: %#v", resp) + } + + warningHandler.clear() treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) if err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, nil) + + if treq.Name != sa.Name { + t.Errorf("expected name=%s, got %s", sa.Name, treq.Name) + } + if treq.Namespace != sa.Namespace { + t.Errorf("expected namespace=%s, got %s", sa.Namespace, treq.Namespace) + } + if treq.CreationTimestamp.IsZero() { + t.Errorf("expected non-zero creation timestamp") + } checkPayload(t, treq.Status.Token, `"system:serviceaccount:myns:test-svcacct"`, "sub") checkPayload(t, treq.Status.Token, `["api"]`, "aud") @@ -220,34 +251,44 @@ func TestServiceAccountTokenCreate(t *testing.T) { }, } + warningHandler.clear() if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), 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(context.TODO(), 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, pod) defer delPod() // right uid treq.Spec.BoundObjectRef.UID = pod.UID + warningHandler.clear() if _, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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") @@ -283,34 +324,44 @@ func TestServiceAccountTokenCreate(t *testing.T) { }, } + warningHandler.clear() if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), 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(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); err == nil { t.Fatalf("expected err creating token bound to nonexistant secret but got: %#v", resp) } + warningHandler.assertEqual(t, nil) secret, delSecret := createDeleteSecret(t, cs, secret) defer delSecret() // right uid treq.Spec.BoundObjectRef.UID = secret.UID + warningHandler.clear() if _, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), 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(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); err == nil { t.Fatalf("expected err creating token bound to secret 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(context.TODO(), 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") @@ -341,9 +392,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { _, del = createDeletePod(t, cs, otherpod) defer del() + warningHandler.clear() if resp, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); err == nil { t.Fatalf("expected err but got: %#v", resp) } + warningHandler.assertEqual(t, nil) }) t.Run("expired token", func(t *testing.T) { @@ -356,10 +409,12 @@ func TestServiceAccountTokenCreate(t *testing.T) { sa, del := createDeleteSvcAcct(t, cs, sa) defer del() + warningHandler.clear() treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) if err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, nil) doTokenReview(t, cs, treq, false) @@ -405,10 +460,12 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer delPod() treq.Spec.BoundObjectRef.UID = pod.UID + warningHandler.clear() treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) if err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, nil) doTokenReview(t, cs, treq, false) @@ -459,10 +516,12 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer delPod() treq.Spec.BoundObjectRef.UID = pod.UID + warningHandler.clear() treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) if err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, nil) // Give some tolerance to avoid flakiness since we are using real time. var leeway int64 = 10 @@ -499,10 +558,12 @@ func TestServiceAccountTokenCreate(t *testing.T) { sa, del := createDeleteSvcAcct(t, cs, sa) defer del() + warningHandler.clear() treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) if err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, nil) doTokenReview(t, cs, treq, true) }) @@ -515,10 +576,12 @@ func TestServiceAccountTokenCreate(t *testing.T) { sa, del := createDeleteSvcAcct(t, cs, sa) defer del() + warningHandler.clear() treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}) if err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, nil) checkPayload(t, treq.Status.Token, `["api"]`, "aud") @@ -543,9 +606,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer originalDelPod() treq.Spec.BoundObjectRef.UID = originalPod.UID + warningHandler.clear() if treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); 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") @@ -584,9 +649,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer originalDelSecret() treq.Spec.BoundObjectRef.UID = originalSecret.UID + warningHandler.clear() if treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); 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") @@ -627,9 +694,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer originalDelSecret() treq.Spec.BoundObjectRef.UID = originalSecret.UID + warningHandler.clear() if treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); 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") @@ -671,9 +740,11 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer originalDelSecret() treq.Spec.BoundObjectRef.UID = originalSecret.UID + warningHandler.clear() if treq, err = cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(context.TODO(), sa.Name, treq, metav1.CreateOptions{}); err != nil { t.Fatalf("err: %v", err) } + warningHandler.assertEqual(t, []string{fmt.Sprintf("requested expiration of %d seconds shortened to %d seconds", tooLongExpirationTime, maxExpirationSeconds)}) checkPayload(t, treq.Status.Token, `"system:serviceaccount:myns:test-svcacct"`, "sub") checkPayload(t, treq.Status.Token, `["api"]`, "aud") @@ -698,6 +769,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { defer del() t.Log("get token") + warningHandler.clear() tokenRequest, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken( context.TODO(), sa.Name, @@ -709,6 +781,7 @@ func TestServiceAccountTokenCreate(t *testing.T) { if err != nil { t.Fatalf("unexpected error creating token: %v", err) } + warningHandler.assertEqual(t, nil) token := tokenRequest.Status.Token if token == "" { t.Fatal("no token") @@ -972,3 +1045,29 @@ func (f *fakeIndexer) GetByKey(key string) (interface{}, bool, error) { obj, err := f.get(namespace, name) return obj, err == nil, err } + +type recordingWarningHandler struct { + warnings []string + + sync.Mutex +} + +func (r *recordingWarningHandler) HandleWarningHeader(code int, agent string, message string) { + r.Lock() + defer r.Unlock() + r.warnings = append(r.warnings, message) +} + +func (r *recordingWarningHandler) clear() { + r.Lock() + defer r.Unlock() + r.warnings = nil +} +func (r *recordingWarningHandler) assertEqual(t *testing.T, expected []string) { + t.Helper() + r.Lock() + defer r.Unlock() + if !reflect.DeepEqual(r.warnings, expected) { + t.Errorf("expected\n\t%v\ngot\n\t%v", expected, r.warnings) + } +} From 42c93b058ec72d859783d2c017f277be2fad1b8b Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Fri, 4 Feb 2022 14:37:31 -0500 Subject: [PATCH 2/3] Add service account token request permissions to edit and admin clusterroles --- plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go | 1 + .../rbac/bootstrappolicy/testdata/cluster-roles.yaml | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go index a89dfc1e96bb3..30eaa7c94c4e0 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go @@ -286,6 +286,7 @@ func ClusterRoles() []rbacv1.ClusterRole { rbacv1helpers.NewRule(Write...).Groups(legacyGroup).Resources("pods", "pods/attach", "pods/proxy", "pods/exec", "pods/portforward").RuleOrDie(), rbacv1helpers.NewRule(Write...).Groups(legacyGroup).Resources("replicationcontrollers", "replicationcontrollers/scale", "serviceaccounts", "services", "services/proxy", "persistentvolumeclaims", "configmaps", "secrets", "events").RuleOrDie(), + rbacv1helpers.NewRule("create").Groups(legacyGroup).Resources("serviceaccounts/token").RuleOrDie(), rbacv1helpers.NewRule(Write...).Groups(appsGroup).Resources( "statefulsets", "statefulsets/scale", diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml index 1aee17d485f50..dad0b7f92cddf 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml @@ -142,6 +142,12 @@ items: - deletecollection - patch - update + - apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create - apiGroups: - apps resources: From fca9b1d9fcc7288ecb93c969ff9907a5def2dc9e Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Mon, 31 Jan 2022 11:46:50 -0500 Subject: [PATCH 3/3] Add command to request a bound service account token --- staging/src/k8s.io/kubectl/go.mod | 1 + .../k8s.io/kubectl/pkg/cmd/create/create.go | 1 + .../kubectl/pkg/cmd/create/create_token.go | 263 ++++++++++++++ .../pkg/cmd/create/create_token_test.go | 330 ++++++++++++++++++ 4 files changed, 595 insertions(+) create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go create mode 100644 staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go diff --git a/staging/src/k8s.io/kubectl/go.mod b/staging/src/k8s.io/kubectl/go.mod index 7161c46a7dc17..ea18aae950ccb 100644 --- a/staging/src/k8s.io/kubectl/go.mod +++ b/staging/src/k8s.io/kubectl/go.mod @@ -41,6 +41,7 @@ require ( k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 k8s.io/metrics v0.0.0 k8s.io/utils v0.0.0-20211208161948-7d6a63dca704 + sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 sigs.k8s.io/kustomize/kustomize/v4 v4.4.1 sigs.k8s.io/kustomize/kyaml v0.13.0 sigs.k8s.io/yaml v1.2.0 diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/create/create.go b/staging/src/k8s.io/kubectl/pkg/cmd/create/create.go index a7d8f8330ac42..e45215cc9cab8 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/create/create.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/create/create.go @@ -153,6 +153,7 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cob cmd.AddCommand(NewCmdCreateJob(f, ioStreams)) cmd.AddCommand(NewCmdCreateCronJob(f, ioStreams)) cmd.AddCommand(NewCmdCreateIngress(f, ioStreams)) + cmd.AddCommand(NewCmdCreateToken(f, ioStreams)) return cmd } diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go new file mode 100644 index 0000000000000..975684c25f19c --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token.go @@ -0,0 +1,263 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package create + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/cli-runtime/pkg/genericclioptions" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/kubectl/pkg/util" + "k8s.io/kubectl/pkg/util/templates" + "k8s.io/kubectl/pkg/util/term" +) + +// TokenOptions is the data required to perform a token request operation. +type TokenOptions struct { + // PrintFlags holds options necessary for obtaining a printer + PrintFlags *genericclioptions.PrintFlags + PrintObj func(obj runtime.Object) error + + // Name and namespace of service account to create a token for + Name string + Namespace string + + // BoundObjectKind is the kind of object to bind the token to. Optional. Can be Pod or Secret. + BoundObjectKind string + // BoundObjectName is the name of the object to bind the token to. Required if BoundObjectKind is set. + BoundObjectName string + // BoundObjectUID is the uid of the object to bind the token to. If unset, defaults to the current uid of the bound object. + BoundObjectUID string + + // Audiences indicate the valid audiences for the requested token. If unset, defaults to the Kubernetes API server audiences. + Audiences []string + + // ExpirationSeconds is the requested token lifetime. Optional. + ExpirationSeconds int64 + + // CoreClient is the API client used to request the token. Required. + CoreClient corev1client.CoreV1Interface + + // IOStreams are the output streams for the operation. Required. + genericclioptions.IOStreams +} + +var ( + tokenLong = templates.LongDesc(`Request a service account token.`) + + tokenExample = templates.Examples(` + # Request a token to authenticate to the kube-apiserver as the service account "myapp" in the current namespace + kubectl create token myapp + + # Request a token for a service account in a custom namespace + kubectl create token myapp --namespace myns + + # Request a token with a custom expiration + kubectl create token myapp --expiration-seconds 600 + + # Request a token with a custom audience + kubectl create token myapp --audience https://example.com + + # Request a token bound to an instance of a Secret object + kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret + + # Request a token bound to an instance of a Secret object with a specific uid + kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret --bound-object-uid 0d4691ed-659b-4935-a832-355f77ee47cc +`) + + boundObjectKindToAPIVersion = map[string]string{ + "Pod": "v1", + "Secret": "v1", + } +) + +func NewTokenOpts(ioStreams genericclioptions.IOStreams) *TokenOptions { + return &TokenOptions{ + PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme), + IOStreams: ioStreams, + } +} + +// NewCmdCreateToken returns an initialized Command for 'create token' sub command +func NewCmdCreateToken(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewTokenOpts(ioStreams) + + cmd := &cobra.Command{ + Use: "token SERVICE_ACCOUNT_NAME", + DisableFlagsInUseLine: true, + Short: "Request a service account token", + Long: tokenLong, + Example: tokenExample, + ValidArgsFunction: util.ResourceNameCompletionFunc(f, "serviceaccount"), + Run: func(cmd *cobra.Command, args []string) { + if err := o.Complete(f, cmd, args); err != nil { + cmdutil.CheckErr(err) + return + } + if err := o.Validate(); err != nil { + cmdutil.CheckErr(err) + return + } + if err := o.Run(); err != nil { + cmdutil.CheckErr(err) + return + } + }, + } + + o.PrintFlags.AddFlags(cmd) + + cmd.Flags().StringArrayVar(&o.Audiences, "audience", o.Audiences, "Audience of the requested token. If unset, defaults to requesting a token for use with the Kubernetes API server. May be repeated to request a token valid for multiple audiences.") + + cmd.Flags().Int64Var(&o.ExpirationSeconds, "expiration-seconds", o.ExpirationSeconds, "Requested lifetime of the issued token. The server may return a token with a longer or shorter lifetime.") + + cmd.Flags().StringVar(&o.BoundObjectKind, "bound-object-kind", o.BoundObjectKind, "Kind of an object to bind the token to. "+ + "Supported kinds are "+strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")+". "+ + "If set, --bound-object-name must be provided.") + cmd.Flags().StringVar(&o.BoundObjectName, "bound-object-name", o.BoundObjectName, "Name of an object to bind the token to. "+ + "The token will expire when the object is deleted. "+ + "Requires --bound-object-kind.") + cmd.Flags().StringVar(&o.BoundObjectUID, "bound-object-uid", o.BoundObjectUID, "UID of an object to bind the token to. "+ + "Requires --bound-object-kind and --bound-object-name. "+ + "If unset, the UID of the existing object is used.") + + return cmd +} + +// Complete completes all the required options +func (o *TokenOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error { + var err error + + o.Name, err = NameFromCommandArgs(cmd, args) + if err != nil { + return err + } + + o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace() + if err != nil { + return err + } + + client, err := f.KubernetesClientSet() + if err != nil { + return err + } + o.CoreClient = client.CoreV1() + + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return err + } + + o.PrintObj = func(obj runtime.Object) error { + return printer.PrintObj(obj, o.Out) + } + + return nil +} + +// Validate makes sure provided values for TokenOptions are valid +func (o *TokenOptions) Validate() error { + if o.CoreClient == nil { + return fmt.Errorf("no client provided") + } + if len(o.Name) == 0 { + return fmt.Errorf("service account name is required") + } + if len(o.Namespace) == 0 { + return fmt.Errorf("--namespace is required") + } + if o.ExpirationSeconds < 0 { + return fmt.Errorf("--expiration-seconds must be positive") + } + for _, aud := range o.Audiences { + if len(aud) == 0 { + return fmt.Errorf("--audience must not be an empty string") + } + } + + if len(o.BoundObjectKind) == 0 { + if len(o.BoundObjectName) > 0 { + return fmt.Errorf("--bound-object-name can only be set if --bound-object-kind is provided") + } + if len(o.BoundObjectUID) > 0 { + return fmt.Errorf("--bound-object-uid can only be set if --bound-object-kind is provided") + } + } else { + if _, ok := boundObjectKindToAPIVersion[o.BoundObjectKind]; !ok { + return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")) + } + if len(o.BoundObjectName) == 0 { + return fmt.Errorf("--bound-object-name is required if --bound-object-kind is provided") + } + } + + return nil +} + +// Run requests a token +func (o *TokenOptions) Run() error { + request := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: o.Audiences, + }, + } + if o.ExpirationSeconds > 0 { + request.Spec.ExpirationSeconds = &o.ExpirationSeconds + } + if len(o.BoundObjectKind) > 0 { + request.Spec.BoundObjectRef = &authenticationv1.BoundObjectReference{ + Kind: o.BoundObjectKind, + APIVersion: boundObjectKindToAPIVersion[o.BoundObjectKind], + Name: o.BoundObjectName, + UID: types.UID(o.BoundObjectUID), + } + } + + response, err := o.CoreClient.ServiceAccounts(o.Namespace).CreateToken(context.TODO(), o.Name, request, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create token: %v", err) + } + if len(response.Status.Token) == 0 { + return fmt.Errorf("failed to create token: no token in server response") + } + + if o.PrintFlags.OutputFlagSpecified() { + return o.PrintObj(response) + } + + if term.IsTerminal(o.Out) { + // include a newline when printing interactively + fmt.Fprintf(o.Out, "%s\n", response.Status.Token) + } else { + // otherwise just print the token + fmt.Fprintf(o.Out, "%s", response.Status.Token) + } + + return nil +} diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go new file mode 100644 index 0000000000000..4785547afe219 --- /dev/null +++ b/staging/src/k8s.io/kubectl/pkg/cmd/create/create_token_test.go @@ -0,0 +1,330 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package create + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "reflect" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/utils/pointer" + kjson "sigs.k8s.io/json" + + authenticationv1 "k8s.io/api/authentication/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest/fake" + cmdtesting "k8s.io/kubectl/pkg/cmd/testing" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" +) + +func TestCreateToken(t *testing.T) { + tests := []struct { + test string + + name string + namespace string + output string + boundObjectKind string + boundObjectName string + boundObjectUID string + audiences []string + expirationSeconds int + + serverResponseToken string + serverResponseError string + + expectRequestPath string + expectTokenRequest *authenticationv1.TokenRequest + + expectStdout string + expectStderr string + }{ + { + test: "simple", + name: "mysa", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "custom namespace", + name: "custom-sa", + namespace: "custom-ns", + + expectRequestPath: "/api/v1/namespaces/custom-ns/serviceaccounts/custom-sa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "yaml", + name: "mysa", + output: "yaml", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "abc", + expectStdout: `apiVersion: authentication.k8s.io/v1 +kind: TokenRequest +metadata: + creationTimestamp: null +spec: + audiences: null + boundObjectRef: null + expirationSeconds: null +status: + expirationTimestamp: null + token: abc +`, + }, + + { + test: "bad bound object kind", + name: "mysa", + boundObjectKind: "Foo", + expectStderr: `error: supported --bound-object-kind values are Pod, Secret`, + }, + { + test: "missing bound object name", + name: "mysa", + boundObjectKind: "Pod", + expectStderr: `error: --bound-object-name is required if --bound-object-kind is provided`, + }, + { + test: "invalid bound object name", + name: "mysa", + boundObjectName: "mypod", + expectStderr: `error: --bound-object-name can only be set if --bound-object-kind is provided`, + }, + { + test: "invalid bound object uid", + name: "mysa", + boundObjectUID: "myuid", + expectStderr: `error: --bound-object-uid can only be set if --bound-object-kind is provided`, + }, + { + test: "valid bound object", + name: "mysa", + + boundObjectKind: "Pod", + boundObjectName: "mypod", + 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: "Pod", + APIVersion: "v1", + Name: "mypod", + UID: "myuid", + }, + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "invalid audience", + name: "mysa", + audiences: []string{"test", "", "test2"}, + expectStderr: `error: --audience must not be an empty string`, + }, + { + test: "valid audiences", + name: "mysa", + + audiences: []string{"test,value1", "test,value2"}, + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"test,value1", "test,value2"}, + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "invalid expiration", + name: "mysa", + expirationSeconds: -1, + expectStderr: `error: --expiration-seconds must be positive`, + }, + { + test: "valid expiration", + name: "mysa", + + expirationSeconds: 1000, + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + Spec: authenticationv1.TokenRequestSpec{ + ExpirationSeconds: pointer.Int64(1000), + }, + }, + serverResponseToken: "abc", + expectStdout: "abc", + }, + + { + test: "server error", + name: "mysa", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseError: "bad bad request", + expectStderr: `error: failed to create token: "bad bad request" is invalid`, + }, + { + test: "server missing token", + name: "mysa", + + expectRequestPath: "/api/v1/namespaces/test/serviceaccounts/mysa/token", + expectTokenRequest: &authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "authentication.k8s.io/v1", Kind: "TokenRequest"}, + }, + serverResponseToken: "", + expectStderr: `error: failed to create token: no token in server response`, + }, + } + + for _, test := range tests { + t.Run(test.test, func(t *testing.T) { + defer cmdutil.DefaultBehaviorOnFatal() + sawError := "" + cmdutil.BehaviorOnFatal(func(str string, code int) { + sawError = str + }) + + namespace := "test" + if test.namespace != "" { + namespace = test.namespace + } + tf := cmdtesting.NewTestFactory().WithNamespace(namespace) + defer tf.Cleanup() + + tf.Client = &fake.RESTClient{} + + var code int + var body []byte + if len(test.serverResponseError) > 0 { + code = 422 + response := apierrors.NewInvalid(schema.GroupKind{Group: "", Kind: ""}, test.serverResponseError, nil) + response.ErrStatus.APIVersion = "v1" + response.ErrStatus.Kind = "Status" + body, _ = json.Marshal(response.ErrStatus) + } else { + code = 200 + response := authenticationv1.TokenRequest{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "authentication.k8s.io/v1", + Kind: "TokenRequest", + }, + Status: authenticationv1.TokenRequestStatus{Token: test.serverResponseToken}, + } + body, _ = json.Marshal(response) + } + + ns := scheme.Codecs.WithoutConversion() + var tokenRequest *authenticationv1.TokenRequest + tf.Client = &fake.RESTClient{ + NegotiatedSerializer: ns, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != test.expectRequestPath { + t.Fatalf("expected %q, got %q", test.expectRequestPath, req.URL.Path) + } + data, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + tokenRequest = &authenticationv1.TokenRequest{} + if strictErrs, err := kjson.UnmarshalStrict(data, tokenRequest); err != nil { + t.Fatal(err) + } else if len(strictErrs) > 0 { + t.Fatal(strictErrs) + } + + return &http.Response{ + StatusCode: code, + Body: ioutil.NopCloser(bytes.NewBuffer(body)), + }, nil + }), + } + tf.ClientConfigVal = cmdtesting.DefaultClientConfig() + + ioStreams, _, stdout, _ := genericclioptions.NewTestIOStreams() + cmd := NewCmdCreateToken(tf, ioStreams) + if test.output != "" { + cmd.Flags().Set("output", test.output) + } + if test.boundObjectKind != "" { + cmd.Flags().Set("bound-object-kind", test.boundObjectKind) + } + if test.boundObjectName != "" { + cmd.Flags().Set("bound-object-name", test.boundObjectName) + } + if test.boundObjectUID != "" { + cmd.Flags().Set("bound-object-uid", test.boundObjectUID) + } + for _, aud := range test.audiences { + cmd.Flags().Set("audience", aud) + } + if test.expirationSeconds != 0 { + cmd.Flags().Set("expiration-seconds", strconv.Itoa(test.expirationSeconds)) + } + cmd.Run(cmd, []string{test.name}) + + if !reflect.DeepEqual(tokenRequest, test.expectTokenRequest) { + t.Fatalf("unexpected request:\n%s", cmp.Diff(test.expectTokenRequest, tokenRequest)) + } + + if stdout.String() != test.expectStdout { + t.Errorf("unexpected stdout:\n%s", cmp.Diff(test.expectStdout, stdout.String())) + } + if sawError != test.expectStderr { + t.Errorf("unexpected stderr:\n%s", cmp.Diff(test.expectStderr, sawError)) + } + }) + } +}