diff --git a/k3k-kubelet/provider/token.go b/k3k-kubelet/provider/token.go index 47f66758..dbf0f720 100644 --- a/k3k-kubelet/provider/token.go +++ b/k3k-kubelet/provider/token.go @@ -2,12 +2,14 @@ package provider import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "regexp" "strconv" "strings" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" authv1 "k8s.io/api/authentication/v1" @@ -29,6 +31,10 @@ func (p *Provider) transformTokens(ctx context.Context, virtualPod, hostPod *cor logger := p.logger.WithValues("namespace", virtualPod.Namespace, "name", virtualPod.Name, "serviceAccountName", virtualPod.Spec.ServiceAccountName) logger.V(1).Info("Transforming service account tokens") + hostPod.Spec.ServiceAccountName = "" + hostPod.Spec.DeprecatedServiceAccount = "" + hostPod.Spec.AutomountServiceAccountToken = new(false) + // transform projected service account token if err := p.transformProjectedTokens(ctx, virtualPod, hostPod); err != nil { return err @@ -77,10 +83,6 @@ func (p *Provider) transformKubeAccessToken(ctx context.Context, virtualPod, hos return err } - hostPod.Spec.ServiceAccountName = "" - hostPod.Spec.DeprecatedServiceAccount = "" - hostPod.Spec.AutomountServiceAccountToken = ptr.To(false) - removeKubeAccessVolume(hostPod) addKubeAccessVolume(hostPod, hostSecret.Name) @@ -292,8 +294,8 @@ func addKubeAccessVolume(pod *corev1.Pod, hostSecretName string) { func generateTokenSecretName(serviceAccountName, tokenPath string, tokenReq *authv1.TokenRequest) string { nameComponents := []string{serviceAccountName} - if tokenReq.Spec.Audiences != nil { - nameComponents = append(nameComponents, tokenReq.Spec.Audiences...) + for _, aud := range tokenReq.Spec.Audiences { + nameComponents = append(nameComponents, sanitizeNameComponent(aud)) } if exp := tokenReq.Spec.ExpirationSeconds; exp != nil { @@ -301,8 +303,25 @@ func generateTokenSecretName(serviceAccountName, tokenPath string, tokenReq *aut } if tokenPath != "" { - nameComponents = append(nameComponents, tokenPath) + nameComponents = append(nameComponents, sanitizeNameComponent(tokenPath)) } return k3kcontroller.SafeConcatNameWithPrefix(nameComponents...) } + +var invalidNameChars = regexp.MustCompile(`[^a-z0-9.-]+`) + +// sanitizeNameComponent lowercases s and replaces any character that is invalid in a Kubernetes +// name with "-". If the result is identical to s it is returned as is, otherwise a 6 character +// sha256 digest of the original is appended to keep distinct inputs distinct, in the form +// -. +func sanitizeNameComponent(s string) string { + sanitized := strings.Trim(invalidNameChars.ReplaceAllString(strings.ToLower(s), "-"), "-.") + if sanitized == s { + return sanitized + } + + digest := sha256.Sum256([]byte(s)) + + return sanitized + "-" + hex.EncodeToString(digest[:])[:6] +} diff --git a/k3k-kubelet/provider/token_test.go b/k3k-kubelet/provider/token_test.go index 73eb834d..5903e75c 100644 --- a/k3k-kubelet/provider/token_test.go +++ b/k3k-kubelet/provider/token_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "k8s.io/utils/ptr" authv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -217,12 +216,12 @@ func Test_addKubeAccessVolume(t *testing.T) { } func Test_virtualSecret(t *testing.T) { - s := virtualSecret("my-secret", "my-ns", "my-sa") + s := virtualSecret("my-secret", "my-ns", "my-serviceaccount") assert.Equal(t, "my-secret", s.Name) assert.Equal(t, "my-ns", s.Namespace) assert.Equal(t, corev1.SecretTypeServiceAccountToken, s.Type) - assert.Equal(t, "my-sa", s.Annotations[corev1.ServiceAccountNameKey]) + assert.Equal(t, "my-serviceaccount", s.Annotations[corev1.ServiceAccountNameKey]) assert.Equal(t, "Secret", s.Kind) assert.Equal(t, "v1", s.APIVersion) } @@ -236,7 +235,7 @@ func Test_generateTokenSecretName(t *testing.T) { want string }{ { - name: "no audiences, no expiration, no path", + name: "empty token request", serviceAccountName: "default", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{}, @@ -244,84 +243,166 @@ func Test_generateTokenSecretName(t *testing.T) { want: "k3k-default", }, { - name: "no audiences, with expiration", + name: "with expiration", serviceAccountName: "default", - tokenPath: "token", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - ExpirationSeconds: ptr.To(int64(3600)), + ExpirationSeconds: new(int64(3600)), }, }, - want: "k3k-default-3600-token", + want: "k3k-default-3600", }, { - name: "with single audience and expiration", - serviceAccountName: "my-sa", - tokenPath: "token", + name: "with audience", + serviceAccountName: "my-serviceaccount", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - Audiences: []string{"api"}, - ExpirationSeconds: ptr.To(int64(3600)), + Audiences: []string{"api", "vault"}, }, }, - want: "k3k-my-sa-api-3600-token", + want: "k3k-my-serviceaccount-api-vault", }, { - name: "with multiple audiences and expiration", - serviceAccountName: "my-sa", - tokenPath: "token", + name: "with empty audiences slice", + serviceAccountName: "my-serviceaccount", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ + Audiences: []string{}, + }, + }, + want: "k3k-my-serviceaccount", + }, + { + name: "with unsanitized audience", + serviceAccountName: "my-serviceaccount", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + Audiences: []string{"https://unsanitized-audience"}, + }, + }, + want: "k3k-my-serviceaccount-https-unsanitized-audience-23d739", + }, + { + name: "with uppercase audience", + serviceAccountName: "my-serviceaccount", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + Audiences: []string{"MyAudience"}, + }, + }, + want: "k3k-my-serviceaccount-myaudience-753a30", + }, + { + name: "with sanitized and unsanitized audiences", + serviceAccountName: "my-serviceaccount", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + Audiences: []string{"api", "vault.example.com:8200"}, + }, + }, + want: "k3k-my-serviceaccount-api-vault.example.com-8200-2a7048", + }, + { + name: "with path", + serviceAccountName: "my-serviceaccount", + tokenPath: "token", + tokenReq: &authv1.TokenRequest{}, + want: "k3k-my-serviceaccount-token", + }, + { + name: "with unsanitized path", + serviceAccountName: "my-serviceaccount", + tokenPath: "other-path/..", + tokenReq: &authv1.TokenRequest{}, + want: "k3k-my-serviceaccount-other-path-0e329c", + }, + { + name: "with expiration and audience", + serviceAccountName: "my-serviceaccount", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + ExpirationSeconds: new(int64(3600)), Audiences: []string{"api", "vault"}, - ExpirationSeconds: ptr.To(int64(3600)), }, }, - want: "k3k-my-sa-api-vault-3600-token", + want: "k3k-my-serviceaccount-api-vault-3600", }, { - name: "with audiences, no expiration", - serviceAccountName: "my-sa", - tokenPath: "vault-token", + name: "with expiration and unsanitized audience", + serviceAccountName: "my-serviceaccount", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - Audiences: []string{"api"}, + ExpirationSeconds: new(int64(3600)), + Audiences: []string{"https://unsanitized-audience"}, }, }, - want: "k3k-my-sa-api-vault-token", + want: "k3k-my-serviceaccount-https-unsanitized-audience-23d739-3600", }, { - name: "different paths produce different names", - serviceAccountName: "my-sa", - tokenPath: "other-path", + name: "with expiration and path", + serviceAccountName: "my-serviceaccount", + tokenPath: "token", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - Audiences: []string{"api"}, - ExpirationSeconds: ptr.To(int64(3600)), + ExpirationSeconds: new(int64(3600)), }, }, - want: "k3k-my-sa-api-3600-other-path", + want: "k3k-my-serviceaccount-3600-token", }, { - name: "long name gets truncated with hash", - serviceAccountName: "my-very-long-service-account-name", - tokenPath: "some-very-long-token-path-value", + name: "with audience and path", + serviceAccountName: "my-serviceaccount", + tokenPath: "token", tokenReq: &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ - Audiences: []string{"some-very-long-audience-string"}, - ExpirationSeconds: ptr.To(int64(3600)), + Audiences: []string{"api", "vault"}, }, }, + want: "k3k-my-serviceaccount-api-vault-token", + }, + { + name: "with unsanitized audience and unsanitized path", + serviceAccountName: "my-serviceaccount", + tokenPath: "other-path/..", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + Audiences: []string{"https://unsanitized-audience"}, + }, + }, + want: "k3k-my-serviceaccount-https-unsanitized-audience-23d739-o-0aa54", + }, + { + name: "with expiration, audience and path", + serviceAccountName: "my-serviceaccount", + tokenPath: "token", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + ExpirationSeconds: new(int64(3600)), + Audiences: []string{"api", "vault"}, + }, + }, + want: "k3k-my-serviceaccount-api-vault-3600-token", + }, + { + name: "with expiration, unsanitized audience and unsanitized path", + serviceAccountName: "my-serviceaccount", + tokenPath: "other-path/..", + tokenReq: &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + ExpirationSeconds: new(int64(3600)), + Audiences: []string{"https://unsanitized-audience"}, + }, + }, + // since the full name is longer than 63 characters, SafeConcatName() will truncate the + // name and append the first 5 characters of the sha256-encoded full name + want: "k3k-my-serviceaccount-https-unsanitized-audience-23d739-3-39d9d", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := generateTokenSecretName(tt.serviceAccountName, tt.tokenPath, tt.tokenReq) - if tt.want != "" { - assert.Equal(t, tt.want, got) - } - - assert.Less(t, len(got), 64, "name should be under 64 characters") + assert.Equal(t, tt.want, got) }) } } diff --git a/tests/e2e/cluster_pod_test.go b/tests/e2e/cluster_pod_test.go index 228c885f..7ecd2a3b 100644 --- a/tests/e2e/cluster_pod_test.go +++ b/tests/e2e/cluster_pod_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "strconv" "time" "k8s.io/client-go/kubernetes" @@ -14,6 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/rancher/k3k/k3k-kubelet/translate" + "github.com/rancher/k3k/pkg/controller" fwk3k "github.com/rancher/k3k/tests/framework/k3k" . "github.com/onsi/ginkgo/v2" @@ -546,4 +548,106 @@ var _ = Context("In a shared cluster", Label(podTestsLabel), Ordered, func() { }) }) }) + + When("creating a Pod with a projected serviceaccount token", func() { + var ( + pod *corev1.Pod + serviceAccountName = "sa-nginx" + serviceAccountTokenExp = int64(3600) + serviceAccountTokenPath = "token" + serviceAccountTokenAudience = "https://kubernetes.default.svc.cluster.local" + serviceAccountTokenSanitizedAudience = "https-kubernetes.default.svc.cluster.local-4f3059" + ) + + BeforeAll(func(ctx context.Context) { + var err error + + serviceaccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountName, + Namespace: "default", + }, + } + serviceaccount, err = virtualCluster.Client.CoreV1().ServiceAccounts(serviceaccount.Namespace).Create(ctx, serviceaccount, metav1.CreateOptions{}) + Expect(err).To(Not(HaveOccurred())) + + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "nginx-", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + AutomountServiceAccountToken: new(false), + Containers: []corev1.Container{{ + Name: "nginx", + Image: "nginx", + VolumeMounts: []corev1.VolumeMount{ + { + Name: "projected-serviceaccount-token-vol", + MountPath: "/var/run/secrets/kubernetes.io/serviceaccount", + }, + }, + }}, + ServiceAccountName: serviceaccount.Name, + Volumes: []corev1.Volume{ + { + Name: "projected-serviceaccount-token-vol", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ServiceAccountToken: &corev1.ServiceAccountTokenProjection{ + Audience: serviceAccountTokenAudience, + ExpirationSeconds: new(serviceAccountTokenExp), + Path: serviceAccountTokenPath, + }, + }, + }, + }, + }, + }, + }, + }, + } + + pod, err = virtualCluster.Client.CoreV1().Pods(pod.Namespace).Create(ctx, pod, metav1.CreateOptions{}) + Expect(err).To(Not(HaveOccurred())) + }) + + It("should have translated projected serviceaccount token to secret", func(ctx context.Context) { + Eventually(func(g Gomega) { + hostPodName := translator.NamespacedName(pod) + + // there is no way to get the result of the token request from the API, so the + // name of the secret holding it has to be rebuilt the same way the provider does + virtualSecretName := controller.SafeConcatNameWithPrefix([]string{serviceAccountName, serviceAccountTokenSanitizedAudience, strconv.FormatInt(serviceAccountTokenExp, 10), serviceAccountTokenPath}...) + hostSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: virtualSecretName, + Namespace: pod.Namespace, + }, + } + translator.TranslateTo(hostSecret) + + hostPod, err := k8s.CoreV1().Pods(hostPodName.Namespace).Get(ctx, hostPodName.Name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(hostPod.Spec.Volumes).To(HaveLen(1)) + g.Expect(hostPod.Spec.Volumes[0].Name).To(Equal("projected-serviceaccount-token-vol")) + g.Expect(hostPod.Spec.Volumes[0].Projected).To(Not(BeNil())) + g.Expect(hostPod.Spec.Volumes[0].Projected.Sources).To(HaveLen(1)) + g.Expect(hostPod.Spec.Volumes[0].Projected.Sources[0].Secret).To(Not(BeNil())) + g.Expect(hostPod.Spec.Volumes[0].Projected.Sources[0].ServiceAccountToken).To(BeNil()) + g.Expect(hostPod.Spec.Volumes[0].Projected.Sources[0].Secret.Name).To(Equal(hostSecret.Name)) + + // verifying that the secret is created on the host cluster + hostSecret, err = k8s.CoreV1().Secrets(hostPodName.Namespace).Get(ctx, hostSecret.Name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(hostSecret.Data).NotTo(BeNil()) + g.Expect(hostSecret.Data[serviceAccountTokenPath]).NotTo(BeEmpty()) + }). + WithPolling(time.Second). + WithTimeout(time.Minute). + Should(Succeed()) + }) + }) })