diff --git a/controllers/helmrelease_controller.go b/controllers/helmrelease_controller.go index 0802ded3e..0f2c595f4 100644 --- a/controllers/helmrelease_controller.go +++ b/controllers/helmrelease_controller.go @@ -80,6 +80,7 @@ type HelmReleaseReconciler struct { EventRecorder kuberecorder.EventRecorder ExternalEventRecorder *events.Recorder MetricsRecorder *metrics.Recorder + DefaultServiceAccount string NoCrossNamespaceRef bool } @@ -456,83 +457,53 @@ func (r *HelmReleaseReconciler) checkDependencies(hr v2.HelmRelease) error { return nil } -func (r *HelmReleaseReconciler) getRESTClientGetter(ctx context.Context, hr v2.HelmRelease) (genericclioptions.RESTClientGetter, error) { - if hr.Spec.KubeConfig == nil { - // impersonate service account if specified - if hr.Spec.ServiceAccountName != "" { - token, err := r.getServiceAccountToken(ctx, hr) - if err != nil { - return nil, fmt.Errorf("could not impersonate ServiceAccount '%s': %w", hr.Spec.ServiceAccountName, err) - } - - config := *r.Config - config.BearerToken = token - return kube.NewInClusterRESTClientGetter(&config, hr.GetReleaseNamespace()), nil - } - - return kube.NewInClusterRESTClientGetter(r.Config, hr.GetReleaseNamespace()), nil +func (r *HelmReleaseReconciler) setImpersonationConfig(restConfig *rest.Config, hr v2.HelmRelease) string { + name := r.DefaultServiceAccount + if sa := hr.Spec.ServiceAccountName; sa != "" { + name = sa } - secretName := types.NamespacedName{ - Namespace: hr.GetNamespace(), - Name: hr.Spec.KubeConfig.SecretRef.Name, - } - var secret corev1.Secret - if err := r.Get(ctx, secretName, &secret); err != nil { - return nil, fmt.Errorf("could not find KubeConfig secret '%s': %w", secretName, err) - } - - var kubeConfig []byte - for k, _ := range secret.Data { - if k == "value" || k == "value.yaml" { - kubeConfig = secret.Data[k] - break - } - } - - if len(kubeConfig) == 0 { - return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a 'value' key", secretName) + if name != "" { + username := fmt.Sprintf("system:serviceaccount:%s:%s", hr.GetNamespace(), name) + restConfig.Impersonate = rest.ImpersonationConfig{UserName: username} + return username } - return kube.NewMemoryRESTClientGetter(kubeConfig, hr.GetReleaseNamespace()), nil + return "" } -func (r *HelmReleaseReconciler) getServiceAccountToken(ctx context.Context, hr v2.HelmRelease) (string, error) { - namespacedName := types.NamespacedName{ - Namespace: hr.Namespace, - Name: hr.Spec.ServiceAccountName, - } +func (r *HelmReleaseReconciler) getRESTClientGetter(ctx context.Context, hr v2.HelmRelease) (genericclioptions.RESTClientGetter, error) { + config := *r.Config + impersonateAccount := r.setImpersonationConfig(&config, hr) - var serviceAccount corev1.ServiceAccount - err := r.Client.Get(ctx, namespacedName, &serviceAccount) - if err != nil { - return "", err - } + if hr.Spec.KubeConfig != nil { + secretName := types.NamespacedName{ + Namespace: hr.GetNamespace(), + Name: hr.Spec.KubeConfig.SecretRef.Name, + } + var secret corev1.Secret + if err := r.Get(ctx, secretName, &secret); err != nil { + return nil, fmt.Errorf("could not find KubeConfig secret '%s': %w", secretName, err) + } - secretName := types.NamespacedName{ - Namespace: hr.Namespace, - Name: hr.Spec.ServiceAccountName, - } + var kubeConfig []byte + for k, _ := range secret.Data { + if k == "value" || k == "value.yaml" { + kubeConfig = secret.Data[k] + break + } + } - for _, secret := range serviceAccount.Secrets { - if strings.HasPrefix(secret.Name, fmt.Sprintf("%s-token", serviceAccount.Name)) { - secretName.Name = secret.Name - break + if len(kubeConfig) == 0 { + return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a 'value' key", secretName) } + return kube.NewMemoryRESTClientGetter(kubeConfig, hr.GetReleaseNamespace(), impersonateAccount), nil } - var secret corev1.Secret - err = r.Client.Get(ctx, secretName, &secret) - if err != nil { - return "", err + if r.DefaultServiceAccount != "" || hr.Spec.ServiceAccountName != "" { + return kube.NewInClusterRESTClientGetter(&config, hr.GetReleaseNamespace()), nil } - 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 kube.NewInClusterRESTClientGetter(r.Config, hr.GetReleaseNamespace()), nil - return token, nil } // composeValues attempts to resolve all v2beta1.ValuesReference resources diff --git a/docs/spec/v2beta1/helmreleases.md b/docs/spec/v2beta1/helmreleases.md index 507ee6cd0..e069cb154 100644 --- a/docs/spec/v2beta1/helmreleases.md +++ b/docs/spec/v2beta1/helmreleases.md @@ -1032,6 +1032,15 @@ When the controller reconciles the `podinfo` release, it will impersonate the `w account. If the chart contains cluster level objects like CRDs, the reconciliation will fail since the account it runs under has no permissions to alter objects outside of the `webapp` namespace. +### Enforce impersonation + +On multi-tenant clusters, platform admins can enforce impersonation with the +`--default-service-account` flag. + +When the flag is set, all HelmReleases which don't have `spec.serviceAccountName` specified +will use the service account name provided by `--default-service-account=` +in the namespace of the object. + ## Remote Clusters / Cluster-API If the `spec.kubeConfig` field is set, Helm actions will run against the default cluster specified @@ -1126,6 +1135,9 @@ kubectl -n default create secret generic prod-kubeconfig \ > from current Cluster API providers. KubeConfigs with cmd-path in them likely won't work without > a custom, per-provider installation of helm-controller. +When both `spec.kubeConfig` and `spec.ServiceAccountName` are specified, +the controller will impersonate the service account on the target cluster. + ## Post Renderers HelmRelease resources has a built-in [Kustomize](https://kubectl.docs.kubernetes.io/references/kustomize/) diff --git a/go.mod b/go.mod index 7d7a12024..48a8db587 100644 --- a/go.mod +++ b/go.mod @@ -65,7 +65,6 @@ require ( github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/fatih/color v1.13.0 // indirect - github.com/fluxcd/pkg/apis/acl v0.0.3 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/go-errors/errors v1.0.1 // indirect github.com/go-logr/zapr v1.2.0 // indirect diff --git a/go.sum b/go.sum index 9ced11d51..71ce431d2 100644 --- a/go.sum +++ b/go.sum @@ -318,7 +318,6 @@ github.com/fluxcd/pkg/apis/kustomize v0.3.1 h1:wmb5D9e1+Rr3/5O3235ERuj+h2VKUArVf github.com/fluxcd/pkg/apis/kustomize v0.3.1/go.mod h1:k2HSRd68UwgNmOYBPOd6WbX6a2MH2X/Jeh7e3s3PFPc= github.com/fluxcd/pkg/apis/meta v0.10.2 h1:pnDBBEvfs4HaKiVAYgz+e/AQ8dLvcgmVfSeBroZ/KKI= github.com/fluxcd/pkg/apis/meta v0.10.2/go.mod h1:KQ2er9xa6koy7uoPMZjIjNudB5p4tXs+w0GO6fRcy7I= -github.com/fluxcd/pkg/runtime v0.12.3 h1:h21AZ3YG5MAP7DxFF9hfKrP+vFzys2L7CkUbPFjbP/0= github.com/fluxcd/pkg/runtime v0.12.3/go.mod h1:imJ2xYy/d4PbSinX2IefmZk+iS2c1P5fY0js8mCE4SM= github.com/fluxcd/pkg/runtime v0.12.4 h1:gA27RG/+adN2/7Qe03PB46Iwmye/MusPCpuS4zQ2fW0= github.com/fluxcd/pkg/runtime v0.12.4/go.mod h1:gspNvhAqodZgSmK1ZhMtvARBf/NGAlxmaZaIOHkJYsc= diff --git a/internal/kube/client.go b/internal/kube/client.go index 776b47e0b..cf853b2c5 100644 --- a/internal/kube/client.go +++ b/internal/kube/client.go @@ -32,25 +32,38 @@ func NewInClusterRESTClientGetter(cfg *rest.Config, namespace string) genericcli flags.BearerToken = &cfg.BearerToken flags.CAFile = &cfg.CAFile flags.Namespace = &namespace + if sa := cfg.Impersonate.UserName; sa != "" { + flags.Impersonate = &sa + } + return flags } // MemoryRESTClientGetter is an implementation of the genericclioptions.RESTClientGetter, // capable of working with an in-memory kubeconfig file. type MemoryRESTClientGetter struct { - kubeConfig []byte - namespace string + kubeConfig []byte + namespace string + impersonateAccount string } -func NewMemoryRESTClientGetter(kubeConfig []byte, namespace string) genericclioptions.RESTClientGetter { +func NewMemoryRESTClientGetter(kubeConfig []byte, namespace string, impersonateAccount string) genericclioptions.RESTClientGetter { return &MemoryRESTClientGetter{ - kubeConfig: kubeConfig, - namespace: namespace, + kubeConfig: kubeConfig, + namespace: namespace, + impersonateAccount: impersonateAccount, } } func (c *MemoryRESTClientGetter) ToRESTConfig() (*rest.Config, error) { - return clientcmd.RESTConfigFromKubeConfig(c.kubeConfig) + cfg, err := clientcmd.RESTConfigFromKubeConfig(c.kubeConfig) + if err != nil { + return nil, err + } + if c.impersonateAccount != "" { + cfg.Impersonate = rest.ImpersonationConfig{UserName: c.impersonateAccount} + } + return cfg, nil } func (c *MemoryRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { @@ -59,6 +72,10 @@ func (c *MemoryRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryI return nil, err } + if c.impersonateAccount != "" { + config.Impersonate = rest.ImpersonationConfig{UserName: c.impersonateAccount} + } + // The more groups you have, the more discovery requests you need to make. // given 25 groups (our groups + a few custom resources) with one-ish version each, discovery needs to make 50 requests // double it just so we don't end up here again for a while. This config is only used for discovery. @@ -88,5 +105,9 @@ func (c *MemoryRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig overrides := &clientcmd.ConfigOverrides{ClusterDefaults: clientcmd.ClusterDefaults} overrides.Context.Namespace = c.namespace + if c.impersonateAccount != "" { + overrides.AuthInfo.Impersonate = c.impersonateAccount + } + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) } diff --git a/main.go b/main.go index 87fc51b54..9cf6fa952 100644 --- a/main.go +++ b/main.go @@ -73,6 +73,7 @@ func main() { logOptions logger.Options aclOptions acl.Options leaderElectionOptions leaderelection.Options + defaultServiceAccount string ) flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") @@ -83,6 +84,7 @@ func main() { flag.BoolVar(&watchAllNamespaces, "watch-all-namespaces", true, "Watch for custom resources in all namespaces, if set to false it will only watch the runtime namespace.") flag.IntVar(&httpRetry, "http-retry", 9, "The maximum number of retries when failing to fetch artifacts over HTTP.") + flag.StringVar(&defaultServiceAccount, "default-service-account", "", "Default service account used for impersonation.") clientOptions.BindFlags(flag.CommandLine) logOptions.BindFlags(flag.CommandLine) aclOptions.BindFlags(flag.CommandLine) @@ -143,6 +145,7 @@ func main() { ExternalEventRecorder: eventRecorder, MetricsRecorder: metricsRecorder, NoCrossNamespaceRef: aclOptions.NoCrossNamespaceRefs, + DefaultServiceAccount: defaultServiceAccount, }).SetupWithManager(mgr, controllers.HelmReleaseReconcilerOptions{ MaxConcurrentReconciles: concurrent, DependencyRequeueInterval: requeueDependency,