Skip to content

Commit

Permalink
Merge pull request #550 from fluxcd/default-service-account
Browse files Browse the repository at this point in the history
Allow setting a default service account for impersonation
  • Loading branch information
stefanprodan committed Jan 31, 2022
2 parents 09e6c29 + 4d7cba9 commit 4b59d77
Show file tree
Hide file tree
Showing 5 changed files with 246 additions and 71 deletions.
6 changes: 4 additions & 2 deletions controllers/kustomization_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ type KustomizationReconciler struct {
StatusPoller *polling.StatusPoller
ControllerName string
NoCrossNamespaceRefs bool
DefaultServiceAccount string
}

// KustomizationReconcilerOptions contains options for the KustomizationReconciler.
type KustomizationReconcilerOptions struct {
MaxConcurrentReconciles int
HTTPRetry int
Expand Down Expand Up @@ -339,7 +341,7 @@ func (r *KustomizationReconciler) reconcile(
}

// setup the Kubernetes client for impersonation
impersonation := NewKustomizeImpersonation(kustomization, r.Client, r.StatusPoller, dirPath)
impersonation := NewKustomizeImpersonation(kustomization, r.Client, r.StatusPoller, r.DefaultServiceAccount)
kubeClient, statusPoller, err := impersonation.GetClient(ctx)
if err != nil {
return kustomizev1.KustomizationNotReady(
Expand Down Expand Up @@ -882,7 +884,7 @@ func (r *KustomizationReconciler) finalize(ctx context.Context, kustomization ku
kustomization.Status.Inventory.Entries != nil {
objects, _ := ListObjectsInInventory(kustomization.Status.Inventory)

impersonation := NewKustomizeImpersonation(kustomization, r.Client, r.StatusPoller, "")
impersonation := NewKustomizeImpersonation(kustomization, r.Client, r.StatusPoller, r.DefaultServiceAccount)
kubeClient, _, err := impersonation.GetClient(ctx)
if err != nil {
// when impersonation fails, log the stale objects and continue with the finalization
Expand Down
98 changes: 33 additions & 65 deletions controllers/kustomization_impersonation.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ package controllers
import (
"context"
"fmt"
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/cli-utils/pkg/kstatus/polling"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand All @@ -32,93 +31,61 @@ import (
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
)

// KustomizeImpersonation holds the state for impersonating a service account.
type KustomizeImpersonation struct {
workdir string
kustomization kustomizev1.Kustomization
statusPoller *polling.StatusPoller
client.Client
kustomization kustomizev1.Kustomization
statusPoller *polling.StatusPoller
defaultServiceAccount string
}

// NewKustomizeImpersonation creates a new KustomizeImpersonation.
func NewKustomizeImpersonation(
kustomization kustomizev1.Kustomization,
kubeClient client.Client,
statusPoller *polling.StatusPoller,
workdir string) *KustomizeImpersonation {
defaultServiceAccount string) *KustomizeImpersonation {
return &KustomizeImpersonation{
workdir: workdir,
kustomization: kustomization,
statusPoller: statusPoller,
Client: kubeClient,
}
}

func (ki *KustomizeImpersonation) GetServiceAccountToken(ctx context.Context) (string, error) {
namespacedName := types.NamespacedName{
Namespace: ki.kustomization.Namespace,
Name: ki.kustomization.Spec.ServiceAccountName,
}

var serviceAccount corev1.ServiceAccount
err := ki.Client.Get(ctx, namespacedName, &serviceAccount)
if err != nil {
return "", err
}

secretName := types.NamespacedName{
Namespace: ki.kustomization.Namespace,
Name: ki.kustomization.Spec.ServiceAccountName,
}

for _, secret := range serviceAccount.Secrets {
if strings.HasPrefix(secret.Name, fmt.Sprintf("%s-token", serviceAccount.Name)) {
secretName.Name = secret.Name
break
}
defaultServiceAccount: defaultServiceAccount,
kustomization: kustomization,
statusPoller: statusPoller,
Client: kubeClient,
}

var secret corev1.Secret
err = ki.Client.Get(ctx, secretName, &secret)
if err != nil {
return "", err
}

var token string
if data, ok := secret.Data["token"]; ok {
token = string(data)
} else {
return "", fmt.Errorf("the service account secret '%s' does not containt a token", secretName.String())
}

return token, nil
}

// GetClient creates a controller-runtime client for talking to a Kubernetes API server.
// If KubeConfig is set, will use the kubeconfig bytes from the Kubernetes secret.
// If ServiceAccountName is set, will use the cluster provided kubeconfig impersonating the SA.
// If --kubeconfig is set, will use the kubeconfig file at that location.
// If spec.KubeConfig is set, use the kubeconfig bytes from the Kubernetes secret.
// Otherwise will assume running in cluster and use the cluster provided kubeconfig.
// If a --default-service-account is set and no spec.ServiceAccountName, use the provided kubeconfig and impersonate the default SA.
// If spec.ServiceAccountName is set, use the provided kubeconfig and impersonate the specified SA.
func (ki *KustomizeImpersonation) GetClient(ctx context.Context) (client.Client, *polling.StatusPoller, error) {
if ki.kustomization.Spec.KubeConfig == nil {
if ki.kustomization.Spec.ServiceAccountName != "" {
return ki.clientForServiceAccount(ctx)
}

switch {
case ki.kustomization.Spec.KubeConfig != nil:
return ki.clientForKubeConfig(ctx)
case ki.defaultServiceAccount != "" || ki.kustomization.Spec.ServiceAccountName != "":
return ki.clientForServiceAccountOrDefault()
default:
return ki.Client, ki.statusPoller, nil
}
return ki.clientForKubeConfig(ctx)
}

func (ki *KustomizeImpersonation) clientForServiceAccount(ctx context.Context) (client.Client, *polling.StatusPoller, error) {
token, err := ki.GetServiceAccountToken(ctx)
if err != nil {
return nil, nil, err
func (ki *KustomizeImpersonation) setImpersonationConfig(restConfig *rest.Config) {
name := ki.defaultServiceAccount
if sa := ki.kustomization.Spec.ServiceAccountName; sa != "" {
name = sa
}
if name != "" {
username := fmt.Sprintf("system:serviceaccount:%s:%s", ki.kustomization.GetNamespace(), name)
restConfig.Impersonate = rest.ImpersonationConfig{UserName: username}
}
}

func (ki *KustomizeImpersonation) clientForServiceAccountOrDefault() (client.Client, *polling.StatusPoller, error) {
restConfig, err := config.GetConfig()
if err != nil {
return nil, nil, err
}
restConfig.BearerToken = token
restConfig.BearerTokenFile = "" // Clear, as it overrides BearerToken
ki.setImpersonationConfig(restConfig)

restMapper, err := apiutil.NewDynamicRESTMapper(restConfig)
if err != nil {
Expand All @@ -145,6 +112,7 @@ func (ki *KustomizeImpersonation) clientForKubeConfig(ctx context.Context) (clie
if err != nil {
return nil, nil, err
}
ki.setImpersonationConfig(restConfig)

restMapper, err := apiutil.NewDynamicRESTMapper(restConfig)
if err != nil {
Expand Down
190 changes: 190 additions & 0 deletions controllers/kustomization_impersonation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/*
Copyright 2022 The Flux 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 controllers

import (
"context"
"fmt"
"testing"
"time"

kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/testserver"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func TestKustomizationReconciler_Impersonation(t *testing.T) {
g := NewWithT(t)
id := "imp-" + randStringRunes(5)
revision := "v1.0.0"

// reset default account
defer func() {
reconciler.DefaultServiceAccount = ""
}()

err := createNamespace(id)
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")

err = createKubeConfigSecret(id)
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")

manifests := func(name string, data string) []testserver.File {
return []testserver.File{
{
Name: "config.yaml",
Body: fmt.Sprintf(`---
apiVersion: v1
kind: ConfigMap
metadata:
name: %[1]s
data:
key: "%[2]s"
`, name, data),
},
}
}

artifact, err := testServer.ArtifactFromFiles(manifests(id, randStringRunes(5)))
g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files")

repositoryName := types.NamespacedName{
Name: randStringRunes(5),
Namespace: id,
}

err = applyGitRepository(repositoryName, artifact, revision)
g.Expect(err).NotTo(HaveOccurred())

kustomizationKey := types.NamespacedName{
Name: randStringRunes(5),
Namespace: id,
}
kustomization := &kustomizev1.Kustomization{
ObjectMeta: metav1.ObjectMeta{
Name: kustomizationKey.Name,
Namespace: kustomizationKey.Namespace,
},
Spec: kustomizev1.KustomizationSpec{
Interval: metav1.Duration{Duration: time.Minute},
Path: "./",
KubeConfig: &kustomizev1.KubeConfig{
SecretRef: meta.LocalObjectReference{
Name: "kubeconfig",
},
},
SourceRef: kustomizev1.CrossNamespaceSourceReference{
Name: repositoryName.Name,
Namespace: repositoryName.Namespace,
Kind: sourcev1.GitRepositoryKind,
},
TargetNamespace: id,
},
}

g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())

resultK := &kustomizev1.Kustomization{}
readyCondition := &metav1.Condition{}

t.Run("reconciles as cluster admin", func(t *testing.T) {
g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
return resultK.Status.LastAppliedRevision == revision
}, timeout, time.Second).Should(BeTrue())

g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason))
})

t.Run("fails to reconcile impersonating the default service account", func(t *testing.T) {
reconciler.DefaultServiceAccount = "default"
revision = "v2.0.0"
err = applyGitRepository(repositoryName, artifact, revision)
g.Expect(err).NotTo(HaveOccurred())

g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
return apimeta.IsStatusConditionFalse(resultK.Status.Conditions, meta.ReadyCondition)
}, timeout, time.Second).Should(BeTrue())

g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationFailedReason))
g.Expect(readyCondition.Message).To(ContainSubstring("system:serviceaccount:%s:default", id))
})

t.Run("reconciles impersonating service account", func(t *testing.T) {
sa := corev1.ServiceAccount{
TypeMeta: metav1.TypeMeta{
Kind: "ServiceAccount",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: id,
},
}
g.Expect(k8sClient.Create(context.Background(), &sa)).To(Succeed())

crb := rbacv1.ClusterRoleBinding{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: id,
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: "test",
Namespace: id,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: "cluster-admin",
},
}
g.Expect(k8sClient.Create(context.Background(), &crb)).To(Succeed())

saK := &kustomizev1.Kustomization{}
err = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), saK)
g.Expect(err).NotTo(HaveOccurred())
saK.Spec.ServiceAccountName = "test"
err = k8sClient.Update(context.Background(), saK)
g.Expect(err).NotTo(HaveOccurred())

revision = "v3.0.0"
err = applyGitRepository(repositoryName, artifact, revision)
g.Expect(err).NotTo(HaveOccurred())

g.Eventually(func() bool {
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
return resultK.Status.LastAppliedRevision == revision
}, timeout, time.Second).Should(BeTrue())

g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason))
})
}
Loading

0 comments on commit 4b59d77

Please sign in to comment.