From 38de0759a4311675c9da3e30be073148f116734d Mon Sep 17 00:00:00 2001 From: Stefan Prodan Date: Thu, 27 Jan 2022 14:34:45 +0200 Subject: [PATCH] Allow setting a default service account for impersonation Introduce the flag `--default-service-account` for allowing cluster admins to enforce impersonation for resources reconciliation. Signed-off-by: Stefan Prodan --- controllers/helmrelease_controller.go | 65 +++++++++++++++------------ docs/spec/v2beta1/helmreleases.md | 12 +++++ internal/kube/client.go | 25 ++++++++++- main.go | 3 ++ 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/controllers/helmrelease_controller.go b/controllers/helmrelease_controller.go index 4b284a145..c99b706d3 100644 --- a/controllers/helmrelease_controller.go +++ b/controllers/helmrelease_controller.go @@ -78,6 +78,7 @@ type HelmReleaseReconciler struct { EventRecorder kuberecorder.EventRecorder ExternalEventRecorder *events.Recorder MetricsRecorder *metrics.Recorder + DefaultServiceAccount string } func (r *HelmReleaseReconciler) SetupWithManager(mgr ctrl.Manager, opts HelmReleaseReconcilerOptions) error { @@ -446,43 +447,51 @@ func (r *HelmReleaseReconciler) checkDependencies(hr v2.HelmRelease) error { return nil } +func (r *HelmReleaseReconciler) setImpersonationConfig(restConfig *rest.Config, hr v2.HelmRelease) { + name := r.DefaultServiceAccount + if sa := hr.Spec.ServiceAccountName; sa != "" { + name = sa + } + if name != "" { + username := fmt.Sprintf("system:serviceaccount:%s:%s", hr.GetNamespace(), name) + restConfig.Impersonate = rest.ImpersonationConfig{UserName: username} + } +} + 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 + r.setImpersonationConfig(&config, hr) - config := *r.Config - config.BearerToken = token - return kube.NewInClusterRESTClientGetter(&config, hr.GetReleaseNamespace()), nil + 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) } - return kube.NewInClusterRESTClientGetter(r.Config, hr.GetReleaseNamespace()), 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) - } + var kubeConfig []byte + for k, _ := range secret.Data { + if k == "value" || k == "value.yaml" { + kubeConfig = secret.Data[k] + break + } + } - 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) } + return kube.NewMemoryRESTClientGetter(kubeConfig, hr.GetReleaseNamespace(), &config), nil } - if len(kubeConfig) == 0 { - return nil, fmt.Errorf("KubeConfig secret '%s' does not contain a 'value' key", secretName) + if r.DefaultServiceAccount != "" || hr.Spec.ServiceAccountName != "" { + return kube.NewInClusterRESTClientGetter(&config, hr.GetReleaseNamespace()), nil } - return kube.NewMemoryRESTClientGetter(kubeConfig, hr.GetReleaseNamespace()), nil + + return kube.NewInClusterRESTClientGetter(r.Config, hr.GetReleaseNamespace()), nil + } func (r *HelmReleaseReconciler) getServiceAccountToken(ctx context.Context, hr v2.HelmRelease) (string, error) { diff --git a/docs/spec/v2beta1/helmreleases.md b/docs/spec/v2beta1/helmreleases.md index b33436bb6..6efeaf4a9 100644 --- a/docs/spec/v2beta1/helmreleases.md +++ b/docs/spec/v2beta1/helmreleases.md @@ -1028,6 +1028,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 @@ -1122,6 +1131,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/internal/kube/client.go b/internal/kube/client.go index 776b47e0b..c4ae74964 100644 --- a/internal/kube/client.go +++ b/internal/kube/client.go @@ -32,6 +32,10 @@ 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 } @@ -40,17 +44,26 @@ func NewInClusterRESTClientGetter(cfg *rest.Config, namespace string) genericcli type MemoryRESTClientGetter struct { kubeConfig []byte namespace string + cfg *rest.Config } -func NewMemoryRESTClientGetter(kubeConfig []byte, namespace string) genericclioptions.RESTClientGetter { +func NewMemoryRESTClientGetter(kubeConfig []byte, namespace string, cfg *rest.Config) genericclioptions.RESTClientGetter { return &MemoryRESTClientGetter{ kubeConfig: kubeConfig, namespace: namespace, + cfg: cfg, } } 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 sa := c.cfg.Impersonate.UserName; sa != "" { + cfg.Impersonate = c.cfg.Impersonate + } + return cfg, nil } func (c *MemoryRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { @@ -59,6 +72,10 @@ func (c *MemoryRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryI return nil, err } + if sa := c.cfg.Impersonate.UserName; sa != "" { + config.Impersonate = c.cfg.Impersonate + } + // 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 sa := c.cfg.Impersonate.UserName; sa != "" { + overrides.AuthInfo.Impersonate = c.cfg.Impersonate.UserName + } + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) } diff --git a/main.go b/main.go index 1a59bf5a3..23cf8aed4 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,7 @@ func main() { clientOptions client.Options logOptions logger.Options leaderElectionOptions leaderelection.Options + defaultServiceAccount string ) flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") @@ -81,6 +82,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) leaderElectionOptions.BindFlags(flag.CommandLine) @@ -139,6 +141,7 @@ func main() { EventRecorder: mgr.GetEventRecorderFor(controllerName), ExternalEventRecorder: eventRecorder, MetricsRecorder: metricsRecorder, + DefaultServiceAccount: defaultServiceAccount, }).SetupWithManager(mgr, controllers.HelmReleaseReconcilerOptions{ MaxConcurrentReconciles: concurrent, DependencyRequeueInterval: requeueDependency,