From 09d106afcba5ceff712f776fa57c41da2a412754 Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Mon, 27 Jul 2026 14:39:32 -0700 Subject: [PATCH 1/8] Gate the RBAC management UI on a per-cluster ConfigMap The RBAC management UI is gated per cluster by the rbac-ui-config ConfigMap in calico-system, keyed rbac-ui-enabled. The operator seeds it disabled and an admin with access to that cluster edits the value to toggle the feature there. ui-apis and rbacsync each read it live for the cluster they are acting on, so a toggle takes effect without restarting or rolling anything. Keep the constants in sync with ui-apis rbacmanagement/gate. The installation controller owns the seed: it runs on management, managed and standalone clusters alike, needs no Manager CR (a managed cluster has none, yet rbacsync still reads that cluster's copy over the tunnel), and is a single reconciler per cluster, so there is no second writer to race. The seed runs right after the namespace it lives in and is gated on the Enterprise variant. The admin's value is protected two ways. The seed is create-only, so an existing ConfigMap is never written and a concurrent create cannot lose the toggle. And it carries no OwnerReference, so deleting the Installation does not garbage-collect the switch and a reinstall does not silently turn the feature off. Deletion is fail-closed: both consumers read a missing ConfigMap as disabled, and the next reconcile re-seeds it disabled rather than restoring a value the operator does not track. The seed is logged, since the feature going quiet is otherwise invisible. Since activation is decided at runtime, the access the feature needs is rendered on every non-multi-tenant Enterprise cluster: the manager cluster and namespaced roles, the rbacsync controller and its roles, the tigera-network-admin rules, and the LDAP egress (gated on LDAP being configured on Authentication). ui-apis and rbacsync get read-only access to the gate, the value being the admin's to set. Multi-tenant management clusters render none of it, matching the force-disable on the ui-apis side. Manager.spec.rbacUI, the RBACManagementEnabled helper, and the Manager CR reads and watches the installation and apiserver controllers held for it are removed, since nothing derives the feature's state from a CR. EV-6816 --- api/v1/manager_types.go | 31 ---- api/v1/manager_types_test.go | 51 ------ api/v1/zz_generated.deepcopy.go | 25 --- .../apiserver/apiserver_controller.go | 15 -- .../installation/core_controller.go | 60 ++++--- .../installation/core_controller_test.go | 79 ++++++++++ .../operator/operator.tigera.io_managers.yaml | 12 -- pkg/render/apiserver.go | 30 ++-- pkg/render/apiserver_test.go | 18 +-- .../kubecontrollers/kube-controllers.go | 37 +++-- .../kubecontrollers/kube-controllers_test.go | 54 ++++--- pkg/render/manager.go | 47 ++++-- pkg/render/manager_test.go | 146 +++++++----------- pkg/render/render_test.go | 6 +- 14 files changed, 290 insertions(+), 321 deletions(-) delete mode 100644 api/v1/manager_types_test.go diff --git a/api/v1/manager_types.go b/api/v1/manager_types.go index f338ce0d99..d6e7223844 100644 --- a/api/v1/manager_types.go +++ b/api/v1/manager_types.go @@ -26,37 +26,6 @@ type ManagerSpec struct { // ManagerDeployment configures the Manager Deployment. // +optional ManagerDeployment *ManagerDeployment `json:"managerDeployment,omitempty"` - - // RBACUI configures the RBAC management UI feature. - // +optional - RBACUI *RBACUI `json:"rbacUI,omitempty"` -} - -// +kubebuilder:validation:Enum=Enabled;Disabled -type RBACUIStatusType string - -const ( - RBACUIDisabled RBACUIStatusType = "Disabled" - RBACUIEnabled RBACUIStatusType = "Enabled" -) - -// RBACUI configures the RBAC management UI. This is a separate control plane -// for Calico Enterprise RBAC that lives alongside, and does not replace, the -// user's ability to configure RBAC themselves. -type RBACUI struct { - // State turns the RBAC management UI on or off. Defaults to Disabled. - // +optional - State *RBACUIStatusType `json:"state,omitempty"` -} - -// RBACManagementEnabled returns true when the Manager CR enables the RBAC -// management UI. Safe to call on a nil receiver; returns false for a nil -// Manager, an unset RBACUI, or any state other than Enabled. -func (m *Manager) RBACManagementEnabled() bool { - if m == nil || m.Spec.RBACUI == nil || m.Spec.RBACUI.State == nil { - return false - } - return *m.Spec.RBACUI.State == RBACUIEnabled } // ManagerDeployment is the configuration for the Manager Deployment. diff --git a/api/v1/manager_types_test.go b/api/v1/manager_types_test.go deleted file mode 100644 index 9a59442dff..0000000000 --- a/api/v1/manager_types_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2026 Tigera, Inc. All rights reserved. - -// 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 v1 - -import "testing" - -func TestRBACManagementEnabled(t *testing.T) { - state := func(s RBACUIStatusType) *RBACUIStatusType { return &s } - - for _, tc := range []struct { - name string - m *Manager - want bool - }{ - // Nil paths: RBACManagementEnabled is called as managerCR.RBACManagementEnabled() - // where managerCR is nil when no Manager CR exists, so a nil receiver (and each - // nil field below) must return false rather than panic. - {name: "nil Manager", m: nil, want: false}, - {name: "nil RBACUI", m: &Manager{}, want: false}, - {name: "nil State", m: &Manager{Spec: ManagerSpec{RBACUI: &RBACUI{}}}, want: false}, - - {name: "State Enabled", m: &Manager{Spec: ManagerSpec{RBACUI: &RBACUI{State: state(RBACUIEnabled)}}}, want: true}, - {name: "State Disabled", m: &Manager{Spec: ManagerSpec{RBACUI: &RBACUI{State: state(RBACUIDisabled)}}}, want: false}, - // Any value other than Enabled is off (the Enum marker rejects this at the - // apiserver, but the helper must not treat a non-empty value as enabled). - {name: "State unrecognized value", m: &Manager{Spec: ManagerSpec{RBACUI: &RBACUI{State: state("SomethingElse")}}}, want: false}, - } { - t.Run(tc.name, func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("RBACManagementEnabled panicked on %s: %v", tc.name, r) - } - }() - if got := tc.m.RBACManagementEnabled(); got != tc.want { - t.Errorf("RBACManagementEnabled() = %v, want %v", got, tc.want) - } - }) - } -} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 30e6ba5f6c..8548208f0e 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -7927,11 +7927,6 @@ func (in *ManagerSpec) DeepCopyInto(out *ManagerSpec) { *out = new(ManagerDeployment) (*in).DeepCopyInto(*out) } - if in.RBACUI != nil { - in, out := &in.RBACUI, &out.RBACUI - *out = new(RBACUI) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagerSpec. @@ -9026,26 +9021,6 @@ func (in *QueryServerLogging) DeepCopy() *QueryServerLogging { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RBACUI) DeepCopyInto(out *RBACUI) { - *out = *in - if in.State != nil { - in, out := &in.State, &out.State - *out = new(RBACUIStatusType) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RBACUI. -func (in *RBACUI) DeepCopy() *RBACUI { - if in == nil { - return nil - } - out := new(RBACUI) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Retention) DeepCopyInto(out *Retention) { *out = *in diff --git a/pkg/controller/apiserver/apiserver_controller.go b/pkg/controller/apiserver/apiserver_controller.go index 700a958891..2fd3e3cb57 100644 --- a/pkg/controller/apiserver/apiserver_controller.go +++ b/pkg/controller/apiserver/apiserver_controller.go @@ -122,13 +122,6 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { return fmt.Errorf("apiserver-controller failed to watch primary resource: %v", err) } - // Watch the Manager CR so toggling spec.rbac re-runs the apiserver - // reconcile (the tigera-network-admin RBAC is gated on it). - err = c.WatchObject(&operatorv1.Manager{}, &handler.EnqueueRequestForObject{}) - if err != nil { - return fmt.Errorf("apiserver-controller failed to watch Manager: %v", err) - } - for _, namespace := range []string{common.OperatorNamespace(), render.APIServerNamespace} { for _, secretName := range []string{render.VoltronTunnelSecretName, render.ManagerTLSSecretName} { if err = utils.AddSecretsWatch(c, secretName, namespace); err != nil { @@ -361,7 +354,6 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re var managementCluster *operatorv1.ManagementCluster var managementClusterConnection *operatorv1.ManagementClusterConnection var keyValidatorConfig authentication.KeyValidatorConfig - var managerCR *operatorv1.Manager includeV3NetworkPolicy := false if installationSpec.Variant.IsEnterprise() { @@ -390,12 +382,6 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re return reconcile.Result{}, err } - managerCR, err = utils.GetManager(ctx, r.client, false, "") - if err != nil { - r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading Manager", err, reqLogger) - return reconcile.Result{}, err - } - if managementClusterConnection != nil && managementCluster != nil { err = fmt.Errorf("having both a ManagementCluster and a ManagementClusterConnection is not supported") r.status.SetDegraded(operatorv1.ResourceValidationError, "", err, reqLogger) @@ -538,7 +524,6 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re ClusterDomain: r.opts.ClusterDomain, Cloud: r.opts.Cloud, RequiresAggregationServer: !r.opts.UseV3CRDs, - RBACManagementEnabled: managerCR.RBACManagementEnabled(), QueryServerTLSKeyPairCertificateManagementOnly: queryServerTLSSecretCertificateManagementOnly, } diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 6c3d08279f..6dc0ec4a5c 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -204,6 +204,11 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { return fmt.Errorf("tigera-installation-controller failed to watch ConfigMap %s: %w", active.ActiveConfigMapName, err) } + // Watched so that deleting the RBAC management UI feature gate re-seeds it. + if err = utils.AddConfigMapWatch(c, render.RBACManagementConfigMapName, common.CalicoNamespace, &handler.EnqueueRequestForObject{}); err != nil { + return fmt.Errorf("tigera-installation-controller failed to watch ConfigMap %s: %w", render.RBACManagementConfigMapName, err) + } + if err = imageset.AddImageSetWatch(c); err != nil { return fmt.Errorf("tigera-installation-controller failed to watch ImageSet: %w", err) } @@ -252,14 +257,6 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { return fmt.Errorf("tigera-installation-controller failed to watch primary resource: %v", err) } - // Watch the Manager CR so changes to spec.rbac re-run the installation - // reconcile (the rbacsync controller in calico-kube-controllers is - // gated on it). - err = c.WatchObject(&operatorv1.Manager{}, &handler.EnqueueRequestForObject{}) - if err != nil { - return fmt.Errorf("tigera-installation-controller failed to watch Manager: %v", err) - } - // watch for change to primary resource LogCollector err = c.WatchObject(&operatorv1.LogCollector{}, &handler.EnqueueRequestForObject{}) if err != nil { @@ -1068,7 +1065,6 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile var managementCluster *operatorv1.ManagementCluster var managementClusterConnection *operatorv1.ManagementClusterConnection - var managerCR *operatorv1.Manager var logCollector *operatorv1.LogCollector if r.enterpriseCRDsExist { logCollector, err = utils.GetLogCollector(ctx, r.client) @@ -1091,12 +1087,6 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, err } - managerCR, err = utils.GetManager(ctx, r.client, false, "") - if err != nil { - r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading Manager", err, reqLogger) - return reconcile.Result{}, err - } - if managementClusterConnection != nil && managementCluster != nil { err = fmt.Errorf("having both a managementCluster and a managementClusterConnection is not supported") r.status.SetDegraded(operatorv1.ResourceValidationError, "", err, reqLogger) @@ -1355,6 +1345,15 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, err } + // Seeded right after the namespace it lives in, so the switch exists even on a + // cluster that is degraded for an unrelated reason. + if instance.Spec.Variant.IsEnterprise() { + if err := r.seedRBACManagementConfigMap(ctx, reqLogger); err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating the RBAC management UI ConfigMap", err, reqLogger) + return reconcile.Result{}, err + } + } + // Build the list of components to render, in rendering order. components := []render.Component{} if newActiveCM != nil && !installationMarkedForDeletion { @@ -1740,9 +1739,8 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile // the kube-controllers component (and deleted when the WAF extension is // disabled); the caBundle is the operator CA that issued the serving // cert above. - WAFWebhookCABundle: certificateManager.KeyPair().GetCertificatePEM(), - RBACManagementEnabled: managerCR.RBACManagementEnabled(), - Cloud: r.cloud, + WAFWebhookCABundle: certificateManager.KeyPair().GetCertificatePEM(), + Cloud: r.cloud, } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) @@ -2384,6 +2382,32 @@ func (r *ReconcileInstallation) checkActive(log logr.Logger) (*corev1.ConfigMap, } } +// seedRBACManagementConfigMap creates the rbac-ui-config ConfigMap, disabled, if it is +// not already present. This controller owns it because every cluster needs one in order +// to control its own access to the RBAC management UI. +// +// Create-only and ownerless, so an admin's toggle survives both reconciles and +// Installation deletion. Deleting the ConfigMap is fail-closed: consumers read a missing +// one as disabled and this re-seeds it disabled, since the previous value is not tracked. +func (r *ReconcileInstallation) seedRBACManagementConfigMap(ctx context.Context, log logr.Logger) error { + cm := render.RBACManagementConfigMap() + + handler := r.newComponentHandler(log, r.client, r.scheme, nil) + handler.SetCreateOnly() + err := handler.CreateOrUpdateOrDelete(ctx, render.NewCreationPassthrough(cm), nil) + if err != nil { + if apierrors.IsAlreadyExists(err) { + // The value is the admin's, so there is nothing to do. + return nil + } + return err + } + + log.Info("Seeded the RBAC management UI feature gate, disabled", + "configMap", fmt.Sprintf("%s/%s", cm.Namespace, cm.Name), "key", render.RBACManagementConfigMapKey) + return nil +} + func (r *ReconcileInstallation) updateCRDs(ctx context.Context, variant operatorv1.ProductVariant, log logr.Logger) error { if !r.manageCRDs { return nil diff --git a/pkg/controller/installation/core_controller_test.go b/pkg/controller/installation/core_controller_test.go index bda4f6065b..71dab29e5d 100644 --- a/pkg/controller/installation/core_controller_test.go +++ b/pkg/controller/installation/core_controller_test.go @@ -36,6 +36,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" schedv1 "k8s.io/api/scheduling/v1" storagev1 "k8s.io/api/storage/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -1381,6 +1382,19 @@ var _ = Describe("Testing core-controller installation", func() { )) }) + // The RBAC management UI is Calico Enterprise only. + It("should not seed the RBAC management UI feature gate for Calico", func() { + cr.Spec.Variant = operator.Calico + Expect(c.Create(ctx, cr)).NotTo(HaveOccurred()) + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + err = c.Get(ctx, client.ObjectKey{ + Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, + }, &corev1.ConfigMap{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected no rbac-ui-config on a Calico cluster") + }) + It("should Reconcile with default config", func() { Expect(c.Create(ctx, cr)).NotTo(HaveOccurred()) _, err := r.Reconcile(ctx, reconcile.Request{}) @@ -2401,6 +2415,71 @@ var _ = Describe("Testing core-controller installation", func() { Expect(c.Get(ctx, client.ObjectKey{Name: render.TyphaTLSSecretName, Namespace: common.OperatorNamespace()}, secret)).ShouldNot(HaveOccurred()) Expect(secret.GetOwnerReferences()).To(HaveLen(1)) }) + + // The operator seeds the switch disabled; the admin owns the value from then + // on, so it must never be overwritten or garbage-collected. + Context("RBAC management UI feature gate", func() { + gateKey := client.ObjectKey{Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace} + + It("seeds the ConfigMap disabled, without an owner reference", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + cm := &corev1.ConfigMap{} + Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) + Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "false")) + // Deleting the Installation must not take the admin's toggle with it. + Expect(cm.GetOwnerReferences()).To(BeEmpty()) + }) + + It("preserves an admin's enabled value across reconciles", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + cm := &corev1.ConfigMap{} + Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) + cm.Data[render.RBACManagementConfigMapKey] = "true" + Expect(c.Update(ctx, cm)).ShouldNot(HaveOccurred()) + + _, err = r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) + Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "true")) + }) + + It("does not overwrite a ConfigMap that already exists when the operator first sees it", func() { + // Created out-of-band (an admin, or the e2e harness) before the + // operator first reconciles. + preexisting := render.RBACManagementConfigMap() + preexisting.Data[render.RBACManagementConfigMapKey] = "true" + Expect(c.Create(ctx, preexisting)).ShouldNot(HaveOccurred()) + + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + cm := &corev1.ConfigMap{} + Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) + Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "true")) + }) + + It("re-seeds the ConfigMap disabled after it is deleted", func() { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + cm := &corev1.ConfigMap{} + Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) + Expect(c.Delete(ctx, cm)).ShouldNot(HaveOccurred()) + + // Fail-closed: the switch comes back off; the operator does not track + // the previous value. + _, err = r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) + Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "false")) + }) + }) }) Context("with a fake component handler", func() { diff --git a/pkg/imports/crds/operator/operator.tigera.io_managers.yaml b/pkg/imports/crds/operator/operator.tigera.io_managers.yaml index 66c1345bf4..5e77333404 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_managers.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_managers.yaml @@ -298,18 +298,6 @@ spec: type: object type: object type: object - rbacUI: - description: RBACUI configures the RBAC management UI feature. - properties: - state: - description: - State turns the RBAC management UI on or off. Defaults - to Disabled. - enum: - - Enabled - - Disabled - type: string - type: object type: object status: description: Most recently observed state for the Calico Enterprise manager. diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index 2cc42b9148..266c08efcb 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -152,10 +152,6 @@ type APIServerConfiguration struct { KubernetesVersion *common.VersionInfo ClusterDomain string - // RBACManagementEnabled gates the RBAC management UI permissions on - // tigera-network-admin. - RBACManagementEnabled bool - // Cloud indicates the API server is being rendered for a Calico Cloud install. It gates // cloud-specific RBAC in the tigera-ui-user / tigera-network-admin cluster roles (Calico Cloud // exposes only per-user UISettings and grants access to runtime logs). When false the RBAC is @@ -2196,20 +2192,18 @@ func (c *apiServerComponent) tigeraNetworkAdminClusterRole() *rbacv1.ClusterRole // impersonating the caller, so the apiserver enforces escalation against the // user's own permissions. The UI reads the role catalogue and manages group // membership through both cluster- and namespace-scoped bindings. - if c.cfg.RBACManagementEnabled { - rules = append(rules, - rbacv1.PolicyRule{ - APIGroups: []string{"rbac.authorization.k8s.io"}, - Resources: []string{"clusterroles", "roles"}, - Verbs: []string{"get", "list", "watch"}, - }, - rbacv1.PolicyRule{ - APIGroups: []string{"rbac.authorization.k8s.io"}, - Resources: []string{"clusterrolebindings", "rolebindings"}, - Verbs: []string{"get", "list", "watch", "create", "update", "delete"}, - }, - ) - } + rules = append(rules, + rbacv1.PolicyRule{ + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterroles", "roles"}, + Verbs: []string{"get", "list", "watch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterrolebindings", "rolebindings"}, + Verbs: []string{"get", "list", "watch", "create", "update", "delete"}, + }, + ) // Privileges for lma.tigera.io have no effect on managed clusters. if c.cfg.ManagementClusterConnection == nil { diff --git a/pkg/render/apiserver_test.go b/pkg/render/apiserver_test.go index cd718e50ea..fd8ab89145 100644 --- a/pkg/render/apiserver_test.go +++ b/pkg/render/apiserver_test.go @@ -330,7 +330,7 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Expect(d.Spec.Template.Spec.Volumes[3].ConfigMap.Name).To(Equal("tigera-ca-bundle")) clusterRole := rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(ConsistOf(networkAdminPolicyRules)) + Expect(clusterRole.Rules).To(ConsistOf(append(networkAdminPolicyRules, rbacManagementNetworkAdminRules...))) clusterRole = rtest.GetResource(resources, "tigera-ui-user", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) Expect(clusterRole.Rules).To(ConsistOf(uiUserPolicyRules)) @@ -384,23 +384,13 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Entry("custom cluster domain", "custom-domain.internal"), ) - It("should gate the RBAC management UI rule on RBACManagementEnabled", func() { - // Disabled (default): tigera-network-admin must not carry the - // escalation-capable RBAC management rule. + // Activation is decided per cluster by the rbac-ui-config ConfigMap an admin + // edits, so tigera-network-admin carries these rules on every Enterprise cluster. + It("should render the RBAC management UI rules on tigera-network-admin", func() { component, err := render.APIServer(cfg) Expect(err).NotTo(HaveOccurred()) resources, _ := component.Objects() clusterRole := rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - for _, rule := range rbacManagementNetworkAdminRules { - Expect(clusterRole.Rules).NotTo(ContainElement(rule)) - } - - // Enabled: the rules are appended. - cfg.RBACManagementEnabled = true - component, err = render.APIServer(cfg) - Expect(err).NotTo(HaveOccurred()) - resources, _ = component.Objects() - clusterRole = rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) for _, rule := range rbacManagementNetworkAdminRules { Expect(clusterRole.Rules).To(ContainElement(rule)) } diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index bb0fecd785..28d2ffc93d 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -169,10 +169,6 @@ type KubeControllersConfiguration struct { // caBundle so the apiserver can verify the in-process webhook endpoint. // Only consulted when WAFGatewayExtensionEnabled is true. WAFWebhookCABundle []byte - - // RBACManagementEnabled mirrors Manager.spec.rbacUI.state and gates the - // rbacsync controller in calico-kube-controllers. - RBACManagementEnabled bool } func NewCalicoKubeControllersPolicy(cfg *KubeControllersConfiguration, defaultDeny *v3.NetworkPolicy) render.Component { @@ -228,12 +224,11 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController enabledControllers = append(enabledControllers, "applicationlayer") } - // Runs the rbacsync controller to reconcile managed ClusterRoles and - // bindings against the tigera-idp-groups ConfigMap. - if cfg.RBACManagementEnabled { - enabledControllers = append(enabledControllers, "rbacsync") - kubeControllerRolePolicyRules = append(kubeControllerRolePolicyRules, rbacSyncControllerRules()...) - } + // Runs the rbacsync controller to reconcile managed ClusterRoles and bindings + // against the tigera-idp-groups ConfigMap. It reads this cluster's + // rbac-ui-config and reconciles nothing while the feature is off there. + enabledControllers = append(enabledControllers, "rbacsync") + kubeControllerRolePolicyRules = append(kubeControllerRolePolicyRules, rbacSyncControllerRules()...) } return &kubeControllersComponent{ @@ -379,8 +374,10 @@ func (c *kubeControllersComponent) Objects() ([]client.Object, []client.Object) c.controllersClusterRoleBinding(), ) objectsToCreate = append(objectsToCreate, c.managedClusterRoleBindings()...) - if c.cfg.RBACManagementEnabled { - objectsToCreate = append(objectsToCreate, c.rbacSyncIDPGroupsRole()...) + // Keep in sync with where the rbacsync controller is enabled, in + // NewCalicoKubeControllers. + if c.kubeControllerName == KubeController && c.cfg.Installation.Variant.IsEnterprise() { + objectsToCreate = append(objectsToCreate, c.rbacSyncNamespacedRole()...) } if len(c.enabledControllers) > 0 { @@ -714,10 +711,10 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) return rules } -// rbacSyncIDPGroupsRole returns the Role + RoleBinding that grants rbacsync -// read access to the tigera-idp-groups ConfigMap in calico-system, its only -// namespaced dependency. -func (c *kubeControllersComponent) rbacSyncIDPGroupsRole() []client.Object { +// rbacSyncNamespacedRole returns the Role + RoleBinding granting rbacsync read access +// to the two ConfigMaps in calico-system it depends on: the IdP groups it reconciles +// bindings from, and the gate telling it whether to reconcile this cluster at all. +func (c *kubeControllersComponent) rbacSyncNamespacedRole() []client.Object { name := "calico-kube-controllers-rbac-sync" return []client.Object{ &rbacv1.Role{ @@ -730,6 +727,14 @@ func (c *kubeControllersComponent) rbacSyncIDPGroupsRole() []client.Object { ResourceNames: []string{"tigera-idp-groups"}, Verbs: []string{"get", "list", "watch"}, }, + { + // This cluster's copy of the gate; a managed cluster's is read over + // that cluster's own client. Read-only: the value is the admin's. + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{render.RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch"}, + }, }, }, &rbacv1.RoleBinding{ diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index e2c484492d..b90dc4e477 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -261,6 +261,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: kubecontrollers.WASMPullSecretName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Secret"}, {name: kubecontrollers.WASMCACertName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ConfigMap"}, @@ -305,7 +307,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.ImagePullSecrets).To(ContainElement(corev1.LocalObjectReference{Name: "tigera-pull-secret"})) envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer,rbacsync", })) // Application-layer reconcilers consume these env vars to program WAF // EnvoyExtensionPolicy attachments. @@ -326,7 +328,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(len(dp.Spec.Template.Spec.Volumes)).To(Equal(1)) clusterRole := rtest.GetResource(resources, kubecontrollers.KubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(38), "cluster role should have 38 rules") + Expect(clusterRole.Rules).To(HaveLen(53), "cluster role should have 53 rules") // Application-layer reconciler RBAC: WAF CRDs (resources, /status, /finalizers). Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ @@ -432,6 +434,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: "calico-kube-controllers-endpoint-controller", ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, @@ -448,12 +452,14 @@ var _ = Describe("kube-controllers rendering tests", func() { } }) - Context("RBAC management UI gate", func() { + // rbacsync runs on every Enterprise cluster and decides per cluster whether to + // reconcile by reading that cluster's rbac-ui-config ConfigMap. + Context("RBAC management UI", func() { BeforeEach(func() { instance.Variant = operatorv1.CalicoEnterprise }) - It("does not enable rbacsync when RBACManagementEnabled is false", func() { + It("enables rbacsync and grants it read access to both ConfigMaps it depends on", func() { component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) resources, _ := component.Objects() @@ -461,14 +467,26 @@ var _ = Describe("kube-controllers rendering tests", func() { dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,rbacsync", })) - Expect(rtest.GetResource(resources, "calico-kube-controllers-rbac-sync", common.CalicoNamespace, "rbac.authorization.k8s.io", "v1", "Role")).To(BeNil()) + nsRole := rtest.GetResource(resources, "calico-kube-controllers-rbac-sync", common.CalicoNamespace, "rbac.authorization.k8s.io", "v1", "Role").(*rbacv1.Role) + Expect(nsRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{"tigera-idp-groups"}, + Verbs: []string{"get", "list", "watch"}, + }), "expected read-only access to tigera-idp-groups in calico-system") + Expect(nsRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{render.RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch"}, + }), "expected read-only access to the feature gate in calico-system") }) - It("enables rbacsync and adds the controller's RBAC when RBACManagementEnabled is true", func() { - cfg.RBACManagementEnabled = true + It("does not enable rbacsync or grant its RBAC for Calico", func() { + instance.Variant = operatorv1.Calico component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) resources, _ := component.Objects() @@ -476,16 +494,10 @@ var _ = Describe("kube-controllers rendering tests", func() { dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,rbacsync", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer", })) - nsRole := rtest.GetResource(resources, "calico-kube-controllers-rbac-sync", common.CalicoNamespace, "rbac.authorization.k8s.io", "v1", "Role").(*rbacv1.Role) - Expect(nsRole.Rules).To(ContainElement(rbacv1.PolicyRule{ - APIGroups: []string{""}, - Resources: []string{"configmaps"}, - ResourceNames: []string{"tigera-idp-groups"}, - Verbs: []string{"get", "list", "watch"}, - }), "expected read-only access to tigera-idp-groups in calico-system") + Expect(rtest.GetResource(resources, "calico-kube-controllers-rbac-sync", common.CalicoNamespace, "rbac.authorization.k8s.io", "v1", "Role")).To(BeNil()) }) }) @@ -572,6 +584,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.ManagedClustersWatchRoleBindingName, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: applicationlayer.WAFWebhookServiceName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, {name: "tigera-waf.applicationlayer.projectcalico.org", ns: "", group: "admissionregistration.k8s.io", version: "v1", kind: "ValidatingWebhookConfiguration"}, @@ -604,7 +618,7 @@ var _ = Describe("kube-controllers rendering tests", func() { envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ Name: "ENABLED_CONTROLLERS", - Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", + Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer,rbacsync", })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) @@ -625,6 +639,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, } @@ -766,7 +782,7 @@ var _ = Describe("kube-controllers rendering tests", func() { // Controller stays wired. Expect(c.Env).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer,rbacsync", })) // Told it is disabled → the reconciler de-programs rather than attaches. Expect(c.Env).To(ContainElement(corev1.EnvVar{Name: "WAF_GATEWAY_EXTENSION_ENABLED", Value: "false"})) @@ -879,6 +895,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, + {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, } diff --git a/pkg/render/manager.go b/pkg/render/manager.go index 06512c4f16..a295df4256 100644 --- a/pkg/render/manager.go +++ b/pkg/render/manager.go @@ -83,6 +83,14 @@ const ( // Keep in sync with ui-apis rbacmanagement/idp LDAPConfigSecretName. RBACManagementLDAPConfigSecretName = "tigera-idp-ldap-config" + // The rbac-ui-config ConfigMap is the whole configuration surface for the RBAC + // management UI: the operator seeds it disabled per cluster, an admin toggles the + // value, and ui-apis and rbacsync read it live so a toggle takes effect without + // rolling any workload. The value is parsed as a boolean; missing or unparsable + // reads as disabled. Keep in sync with ui-apis rbacmanagement/gate. + RBACManagementConfigMapName = "rbac-ui-config" + RBACManagementConfigMapKey = "rbac-ui-enabled" + // The name of the TLS certificate used by Voltron to authenticate connections from managed // cluster clients talking to Linseed. VoltronLinseedTLS = "calico-voltron-linseed-tls" @@ -296,11 +304,11 @@ func (c *managerComponent) Objects() ([]client.Object, []client.Object) { objsToCreate = append(objsToCreate, managerClusterRoleBinding(c.cfg.Tenant, c.cfg.BindingNamespaces, c.cfg.OSSTenantNamespaces), - managerClusterRole(false, c.cfg.Installation.KubernetesProvider, c.cfg.Tenant, c.cfg.Manager.RBACManagementEnabled()), + managerClusterRole(false, c.cfg.Installation.KubernetesProvider, c.cfg.Tenant), c.managedClustersWatchRoleBinding(), ) objsToCreate = append(objsToCreate, c.managedClustersUpdateRBAC()...) - if c.cfg.Manager.RBACManagementEnabled() && !c.cfg.Tenant.MultiTenant() { + if !c.cfg.Tenant.MultiTenant() { objsToCreate = append(objsToCreate, c.rbacManagementUINamespacedRole()...) } if c.cfg.Tenant.MultiTenant() { @@ -777,7 +785,6 @@ func (c *managerComponent) managerUIAPIsContainer() corev1.Container { {Name: "LINSEED_CLIENT_KEY", Value: keyPath}, {Name: "ELASTIC_KIBANA_DISABLED", Value: strconv.FormatBool(c.cfg.Tenant.MultiTenant())}, {Name: "VOLTRON_URL", Value: ManagerService(c.cfg.Tenant)}, - {Name: "RBAC_UI_ENABLED", Value: strconv.FormatBool(c.cfg.Manager.RBACManagementEnabled() && !c.cfg.Tenant.MultiTenant())}, } // Determine the Linseed location. Use code default unless in multi-tenant mode, @@ -987,8 +994,7 @@ func (c *managerComponent) managedClustersUpdateRBAC() []client.Object { } // managerClusterRole returns a clusterrole that allows authn/authz review requests. -// When rbacManagementEnabled is true it also carries the RBAC management UI rules. -func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provider, tenant *operatorv1.Tenant, rbacManagementEnabled bool) *rbacv1.ClusterRole { +func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provider, tenant *operatorv1.Tenant) *rbacv1.ClusterRole { // Different tenant types use different permission sets. name := ManagerClusterRole if tenant.ManagedClusterIsCalico() { @@ -1194,10 +1200,9 @@ func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provi }, } - // Not rendered on multi-tenant management clusters. Keep this condition in - // sync with the rbacManagementUINamespacedRole gate; the cluster rules and - // the namespaced grant are rendered together. - if rbacManagementEnabled && !tenant.MultiTenant() { + // Keep this condition in sync with the rbacManagementUINamespacedRole gate; the + // cluster rules and the namespaced grant are rendered together. + if !tenant.MultiTenant() { cr.Rules = append(cr.Rules, rbacManagementUIRules()...) } @@ -1235,6 +1240,20 @@ func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provi return cr } +// RBACManagementConfigMap returns the rbac-ui-config ConfigMap seeded with the feature +// disabled. Seeded on every Enterprise cluster, including ones where the feature is +// never turned on, so the admin always has a switch to find. +func RBACManagementConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: RBACManagementConfigMapName, + Namespace: common.CalicoNamespace, + }, + Data: map[string]string{RBACManagementConfigMapKey: "false"}, + } +} + // rbacManagementUIRules returns the cluster-scoped rules the RBAC management // UI adds to calico-manager-role. Named-resource access is scoped separately // on rbacManagementUINamespacedRole. @@ -1280,6 +1299,14 @@ func (c *managerComponent) rbacManagementUINamespacedRole() []client.Object { ResourceNames: []string{"tigera-idp-groups"}, Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, }, + { + // The gate ui-apis watches to decide whether the feature is active + // on this cluster. Read-only: the value is the admin's to set. + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch"}, + }, }, }, &rbacv1.RoleBinding{ @@ -1371,7 +1398,7 @@ func (c *managerComponent) managerCalicoSystemNetworkPolicy() *v3.NetworkPolicy }) } - if c.cfg.Manager.RBACManagementEnabled() && !c.cfg.Tenant.MultiTenant() && + if !c.cfg.Tenant.MultiTenant() && c.cfg.Authentication != nil && c.cfg.Authentication.Spec.LDAP != nil { // LDAP/AD egress (389, 636) for the RBAC-UI directory sync, gated on LDAP // being configured on the Authentication CR. The destination is scoped to diff --git a/pkg/render/manager_test.go b/pkg/render/manager_test.go index 5579011ec4..49e963ca4c 100644 --- a/pkg/render/manager_test.go +++ b/pkg/render/manager_test.go @@ -31,7 +31,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" @@ -89,6 +88,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: render.LegacyManagerNamespace}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerUserSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, @@ -162,7 +163,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { {Name: "LINSEED_CLIENT_KEY", Value: "/internal-manager-tls/tls.key"}, {Name: "ELASTIC_KIBANA_DISABLED", Value: "false"}, {Name: "VOLTRON_URL", Value: render.ManagerService(nil)}, - {Name: "RBAC_UI_ENABLED", Value: "false"}, } Expect(uiAPIs.Env).To(Equal(uiAPIsExpectedEnvVars)) @@ -487,6 +487,13 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Resources: []string{"clusterroles", "clusterrolebindings", "roles", "rolebindings"}, Verbs: []string{"get", "list", "watch"}, }, + // RBAC management UI: present on every non-multi-tenant cluster, since the + // feature is activated at runtime from the rbac-ui-config ConfigMap. + { + APIGroups: []string{"operator.tigera.io"}, + Resources: []string{"compliances"}, + Verbs: []string{"get"}, + }, })) roleBindingWatchManagedClusters := rtest.GetResource(resourcesToCreate, render.ManagerManagedClustersWatchRoleBindingName, "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding").(*rbacv1.ClusterRoleBinding) Expect(roleBindingWatchManagedClusters.RoleRef.Name).To(Equal(render.ManagedClustersWatchClusterRoleName)) @@ -620,6 +627,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerServiceName, Namespace: render.ManagerNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.LegacyManagerServiceName, Namespace: render.LegacyManagerNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerDeploymentName, Namespace: render.ManagerNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}}, @@ -839,6 +848,13 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Resources: []string{"clusterroles", "clusterrolebindings", "roles", "rolebindings"}, Verbs: []string{"get", "list", "watch"}, }, + // RBAC management UI: present on every non-multi-tenant cluster, since the + // feature is activated at runtime from the rbac-ui-config ConfigMap. + { + APIGroups: []string{"operator.tigera.io"}, + Resources: []string{"compliances"}, + Verbs: []string{"get"}, + }, })) }) @@ -937,6 +953,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: render.LegacyManagerNamespace}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerUserSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, @@ -1745,79 +1763,32 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { } }) - rbacUIEnabledEnv := func(d *appsv1.Deployment) corev1.EnvVar { - for _, c := range d.Spec.Template.Spec.Containers { - if c.Name == render.UIAPIsName { - for _, e := range c.Env { - if e.Name == "RBAC_UI_ENABLED" { - return e - } - } - } - } - return corev1.EnvVar{} - } - - // Namespaced Role carries the create rule on ConfigMaps/Secrets — this - // is the stable presence check for the RBAC management UI gate. + // The create rule on ConfigMaps/Secrets is unique to this Role, so it + // identifies the RBAC management UI access without matching the whole rule set. nsCreateRule := rbacv1.PolicyRule{ APIGroups: []string{""}, Resources: []string{"configmaps", "secrets"}, Verbs: []string{"create"}, } - It("renders RBAC_UI_ENABLED=false and no namespaced RBAC UI role when rbac is unset", func() { - resources, _ := renderObjects(renderConfig{ - installation: installation, - ns: render.ManagerNamespace, - }) - d := rtest.GetResource(resources, render.ManagerDeploymentName, render.ManagerNamespace, appsv1.GroupName, "v1", "Deployment").(*appsv1.Deployment) - Expect(rbacUIEnabledEnv(d)).To(Equal(corev1.EnvVar{Name: "RBAC_UI_ENABLED", Value: "false"})) - - Expect(rtest.GetResource(resources, render.ManagerClusterRole, render.ManagerNamespace, rbacv1.GroupName, "v1", "Role")).To(BeNil()) - }) - - It("renders RBAC_UI_ENABLED=false and no namespaced RBAC UI role when the Manager exists but rbacUI is unset", func() { - resources, _ := renderObjects(renderConfig{ - installation: installation, - ns: render.ManagerNamespace, - manager: &operatorv1.Manager{Spec: operatorv1.ManagerSpec{}}, - }) - d := rtest.GetResource(resources, render.ManagerDeploymentName, render.ManagerNamespace, appsv1.GroupName, "v1", "Deployment").(*appsv1.Deployment) - Expect(rbacUIEnabledEnv(d)).To(Equal(corev1.EnvVar{Name: "RBAC_UI_ENABLED", Value: "false"})) - - Expect(rtest.GetResource(resources, render.ManagerClusterRole, render.ManagerNamespace, rbacv1.GroupName, "v1", "Role")).To(BeNil()) - }) + // Read-only access to the gate ui-apis watches. + gateReadRule := rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{render.RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch"}, + } - It("renders RBAC_UI_ENABLED=true and the namespaced RBAC UI role when rbacUI.state is Enabled", func() { + // Activation is decided at runtime from the rbac-ui-config ConfigMap, so the + // access is in place on every non-multi-tenant cluster. + It("renders the namespaced RBAC UI role with read access to the feature gate", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - manager: &operatorv1.Manager{ - Spec: operatorv1.ManagerSpec{ - RBACUI: &operatorv1.RBACUI{State: ptr.To(operatorv1.RBACUIEnabled)}, - }, - }, }) - d := rtest.GetResource(resources, render.ManagerDeploymentName, render.ManagerNamespace, appsv1.GroupName, "v1", "Deployment").(*appsv1.Deployment) - Expect(rbacUIEnabledEnv(d)).To(Equal(corev1.EnvVar{Name: "RBAC_UI_ENABLED", Value: "true"})) - role := rtest.GetResource(resources, render.ManagerClusterRole, render.ManagerNamespace, rbacv1.GroupName, "v1", "Role").(*rbacv1.Role) Expect(role.Rules).To(ContainElement(nsCreateRule)) - }) - - It("renders RBAC_UI_ENABLED=false when rbacUI.state is Disabled", func() { - resources, _ := renderObjects(renderConfig{ - installation: installation, - ns: render.ManagerNamespace, - manager: &operatorv1.Manager{ - Spec: operatorv1.ManagerSpec{ - RBACUI: &operatorv1.RBACUI{State: ptr.To(operatorv1.RBACUIDisabled)}, - }, - }, - }) - d := rtest.GetResource(resources, render.ManagerDeploymentName, render.ManagerNamespace, appsv1.GroupName, "v1", "Deployment").(*appsv1.Deployment) - Expect(rbacUIEnabledEnv(d)).To(Equal(corev1.EnvVar{Name: "RBAC_UI_ENABLED", Value: "false"})) + Expect(role.Rules).To(ContainElement(gateReadRule)) }) It("does not add the manager-side RBAC rules in multi-tenant mode", func() { @@ -1832,13 +1803,18 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { ManagedClusterVariant: &operatorv1.Calico, }, }, - manager: &operatorv1.Manager{ - Spec: operatorv1.ManagerSpec{ - RBACUI: &operatorv1.RBACUI{State: ptr.To(operatorv1.RBACUIEnabled)}, - }, - }, }) Expect(rtest.GetResource(resources, render.ManagerClusterRole, "tenant-a", rbacv1.GroupName, "v1", "Role")).To(BeNil()) + + // The cluster rules and the namespaced grant are gated on the same + // condition and must be dropped together; assert both halves so the + // two gates cannot drift apart. + clusterRole := rtest.GetResource(resources, render.ManagerManagedCalicoClusterRole, "", rbacv1.GroupName, "v1", "ClusterRole").(*rbacv1.ClusterRole) + Expect(clusterRole.Rules).NotTo(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"operator.tigera.io"}, + Resources: []string{"compliances"}, + Verbs: []string{"get"}, + })) }) Context("LDAP egress network policy gate", func() { @@ -1853,14 +1829,10 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Ports: networkpolicy.Ports(389, 636), }, } - rbacUIManager := &operatorv1.Manager{ - Spec: operatorv1.ManagerSpec{RBACUI: &operatorv1.RBACUI{State: ptr.To(operatorv1.RBACUIEnabled)}}, - } - - It("adds an unscoped LDAP egress when RBAC UI and LDAP auth are configured but no host is set", func() { + It("adds an unscoped LDAP egress when LDAP auth is configured but no host is set", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - manager: rbacUIManager, ldapConfigured: true, + ldapConfigured: true, }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(ldapEgress)) @@ -1869,8 +1841,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("scopes LDAP egress to a Domains match when the LDAP host is a hostname", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - manager: rbacUIManager, ldapConfigured: true, - ldapHost: "ad.example.com:636", + ldapConfigured: true, + ldapHost: "ad.example.com:636", }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(v3.Rule{ @@ -1888,8 +1860,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("scopes LDAP egress to a /32 Nets match when the LDAP host is an IPv4 address", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - manager: rbacUIManager, ldapConfigured: true, - ldapHost: "10.20.30.40:389", + ldapConfigured: true, + ldapHost: "10.20.30.40:389", }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(v3.Rule{ @@ -1907,8 +1879,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("scopes LDAP egress to a /128 Nets match when the LDAP host is an IPv6 address", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - manager: rbacUIManager, ldapConfigured: true, - ldapHost: "[2001:db8::1]:636", + ldapConfigured: true, + ldapHost: "[2001:db8::1]:636", }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(v3.Rule{ @@ -1923,25 +1895,16 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Expect(policy.Spec.Egress).NotTo(ContainElement(ldapEgress)) }) - It("omits LDAP egress when LDAP auth is not configured, even with RBAC UI enabled", func() { + It("omits LDAP egress when LDAP auth is not configured", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - manager: rbacUIManager, ldapConfigured: false, - }) - policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) - Expect(policy.Spec.Egress).NotTo(ContainElement(ldapEgress)) - }) - - It("omits LDAP egress when LDAP auth is configured but RBAC UI is disabled", func() { - resources, _ := renderObjects(renderConfig{ - installation: installation, ns: render.ManagerNamespace, - ldapConfigured: true, + ldapConfigured: false, }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).NotTo(ContainElement(ldapEgress)) }) - It("omits LDAP egress in multi-tenant mode even with RBAC UI enabled and LDAP auth configured", func() { + It("omits LDAP egress in multi-tenant mode even with LDAP auth configured", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: "tenant-a", @@ -1953,7 +1916,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { ManagedClusterVariant: &operatorv1.Calico, }, }, - manager: rbacUIManager, ldapConfigured: true, }) policy := testutils.GetCalicoSystemPolicyFromResources( diff --git a/pkg/render/render_test.go b/pkg/render/render_test.go index 2b97cfaeb9..7839c72fd2 100644 --- a/pkg/render/render_test.go +++ b/pkg/render/render_test.go @@ -234,7 +234,9 @@ var _ = Describe("Rendering tests", func() { instance.NodeMetricsPort = &nodeMetricsPort c, err := allCalicoComponents(k8sServiceEp, instance, nil, nil, nil, typhaNodeTLS, nil, nil, false, "", dns.DefaultClusterDomain, 9094, 0, nil, nil) Expect(err).To(BeNil(), "Expected Calico to create successfully %s", err) - Expect(componentCount(c)).To(Equal((5 + 3 + 4 + 1 + 6 + 6 + 1 + 2) + 1 + 1)) + // The trailing 2 are the rbacsync Role + RoleBinding, rendered on Enterprise so + // the controller can read the ConfigMaps that drive it. + Expect(componentCount(c)).To(Equal((5 + 3 + 4 + 1 + 6 + 6 + 1 + 2) + 1 + 1 + 2)) }) It("should render all resources when variant is Tigera Secure and Management Cluster", func() { @@ -276,6 +278,8 @@ var _ = Describe("Rendering tests", func() { &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: common.KubeControllersDeploymentName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: common.KubeControllersDeploymentName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: kubecontrollers.ManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-rbac-sync", Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-rbac-sync", Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: common.KubeControllersDeploymentName, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-metrics", Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, From f0475288155362ba0df70b637f3040be3762a3f4 Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 10:40:39 -0700 Subject: [PATCH 2/8] Read the RBAC management UI gate in the operator Adds RBACManagementEnabled, parsed with strconv.ParseBool so True or 1 also work; a missing ConfigMap, missing key or unparsable value reads as disabled. The installation, apiserver and manager controllers each read the ConfigMap and watch it, so a toggle re-runs the reconcile. Nothing is gated on the value yet. EV-6816 --- .../apiserver/apiserver_controller.go | 20 +++++++++++++++++++ .../installation/core_controller.go | 17 ++++++++++++++-- pkg/controller/manager/manager_controller.go | 17 ++++++++++++++++ pkg/render/apiserver.go | 5 +++++ .../kubecontrollers/kube-controllers.go | 4 ++++ pkg/render/manager.go | 18 +++++++++++++++++ 6 files changed, 79 insertions(+), 2 deletions(-) diff --git a/pkg/controller/apiserver/apiserver_controller.go b/pkg/controller/apiserver/apiserver_controller.go index 2fd3e3cb57..487ad6852a 100644 --- a/pkg/controller/apiserver/apiserver_controller.go +++ b/pkg/controller/apiserver/apiserver_controller.go @@ -104,6 +104,12 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { } if opts.EnterpriseCRDExists { + // Watched so that toggling the RBAC management UI re-renders the + // tigera-network-admin rules gated on it. + if err = utils.AddConfigMapWatch(c, render.RBACManagementConfigMapName, common.CalicoNamespace, &handler.EnqueueRequestForObject{}); err != nil { + return fmt.Errorf("apiserver-controller failed to watch ConfigMap %s: %w", render.RBACManagementConfigMapName, err) + } + // Watch for changes to ApplicationLayer err = c.WatchObject(&operatorv1.ApplicationLayer{ObjectMeta: metav1.ObjectMeta{Name: utils.DefaultEnterpriseInstanceKey.Name}}, &handler.EnqueueRequestForObject{}) if err != nil { @@ -354,6 +360,7 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re var managementCluster *operatorv1.ManagementCluster var managementClusterConnection *operatorv1.ManagementClusterConnection var keyValidatorConfig authentication.KeyValidatorConfig + var rbacManagementEnabled bool includeV3NetworkPolicy := false if installationSpec.Variant.IsEnterprise() { @@ -364,6 +371,18 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re return reconcile.Result{}, err } + // The RBAC management UI switch the admin owns. The installation controller + // seeds it; this controller only reads it, and is watching it so a toggle + // re-renders tigera-network-admin. + gate, err := utils.GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ + Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, + }, r.client) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading the RBAC management UI ConfigMap", err, reqLogger) + return reconcile.Result{}, err + } + rbacManagementEnabled = render.RBACManagementEnabled(gate) + applicationLayer, err = utils.GetApplicationLayer(ctx, r.client) if err != nil { r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading ApplicationLayer", err, reqLogger) @@ -524,6 +543,7 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re ClusterDomain: r.opts.ClusterDomain, Cloud: r.opts.Cloud, RequiresAggregationServer: !r.opts.UseV3CRDs, + RBACManagementEnabled: rbacManagementEnabled, QueryServerTLSKeyPairCertificateManagementOnly: queryServerTLSSecretCertificateManagementOnly, } diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 6dc0ec4a5c..84fc38decf 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -1347,11 +1347,23 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile // Seeded right after the namespace it lives in, so the switch exists even on a // cluster that is degraded for an unrelated reason. + var rbacManagementEnabled bool if instance.Spec.Variant.IsEnterprise() { if err := r.seedRBACManagementConfigMap(ctx, reqLogger); err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating the RBAC management UI ConfigMap", err, reqLogger) return reconcile.Result{}, err } + + // Read back the switch the admin owns. The ConfigMap is watched, so flipping it + // re-runs this reconcile and the access below follows the new value. + gate, err := utils.GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ + Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, + }, r.client) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading the RBAC management UI ConfigMap", err, reqLogger) + return reconcile.Result{}, err + } + rbacManagementEnabled = render.RBACManagementEnabled(gate) } // Build the list of components to render, in rendering order. @@ -1739,8 +1751,9 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile // the kube-controllers component (and deleted when the WAF extension is // disabled); the caBundle is the operator CA that issued the serving // cert above. - WAFWebhookCABundle: certificateManager.KeyPair().GetCertificatePEM(), - Cloud: r.cloud, + WAFWebhookCABundle: certificateManager.KeyPair().GetCertificatePEM(), + Cloud: r.cloud, + RBACManagementEnabled: rbacManagementEnabled, } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) diff --git a/pkg/controller/manager/manager_controller.go b/pkg/controller/manager/manager_controller.go index 993e229821..2df55edac7 100644 --- a/pkg/controller/manager/manager_controller.go +++ b/pkg/controller/manager/manager_controller.go @@ -188,6 +188,11 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { return fmt.Errorf("manager-controller failed to watch ConfigMap resource %s: %w", tigerakvc.StaticWellKnownJWKSConfigMapName, err) } + // Watched so that toggling the RBAC management UI re-renders the access gated on it. + if err = utils.AddConfigMapWatch(c, render.RBACManagementConfigMapName, common.CalicoNamespace, eventHandler); err != nil { + return fmt.Errorf("manager-controller failed to watch ConfigMap resource %s: %w", render.RBACManagementConfigMapName, err) + } + if err = utils.AddConfigMapWatch(c, relasticsearch.ClusterConfigConfigMapName, common.OperatorNamespace(), eventHandler); err != nil { return fmt.Errorf("compliance-controller failed to watch the ConfigMap resource: %w", err) } @@ -706,6 +711,17 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ } } + // The RBAC management UI switch the admin owns. The installation controller seeds + // it; this controller only reads it, and is watching it so a toggle re-renders the + // access gated on it. + rbacGate, err := utils.GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ + Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, + }, r.client) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading the RBAC management UI ConfigMap", err, logc) + return reconcile.Result{}, err + } + managerCfg := &render.ManagerConfiguration{ VoltronRouteConfig: routeConfig, KeyValidatorConfig: keyValidatorConfig, @@ -735,6 +751,7 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ Manager: instance, Authentication: authenticationCR, KibanaEnabled: kibanaEnabled, + RBACManagementEnabled: render.RBACManagementEnabled(rbacGate), CACertCommonName: certificateManager.CACertCommonName(), Cloud: r.opts.Cloud, CloudResources: mcr, diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index 266c08efcb..f1d5181b65 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -158,6 +158,11 @@ type APIServerConfiguration struct { // exactly the regular Calico/Calico Enterprise RBAC. Cloud bool + // RBACManagementEnabled is the value of the rbac-ui-config gate for this cluster. + // The escalation-capable RBAC management UI rules are added to + // tigera-network-admin only while it is true. + RBACManagementEnabled bool + // Whether or not we should run the aggregation API server for projectcalico.org/v3 APIs // as part of this component. RequiresAggregationServer bool diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 28d2ffc93d..41ab048611 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -169,6 +169,10 @@ type KubeControllersConfiguration struct { // caBundle so the apiserver can verify the in-process webhook endpoint. // Only consulted when WAFGatewayExtensionEnabled is true. WAFWebhookCABundle []byte + + // RBACManagementEnabled is the value of the rbac-ui-config gate for this cluster. + // The rbacsync controller and its access are rendered only while it is true. + RBACManagementEnabled bool } func NewCalicoKubeControllersPolicy(cfg *KubeControllersConfiguration, defaultDeny *v3.NetworkPolicy) render.Component { diff --git a/pkg/render/manager.go b/pkg/render/manager.go index a295df4256..947944d7cd 100644 --- a/pkg/render/manager.go +++ b/pkg/render/manager.go @@ -229,6 +229,11 @@ type ManagerConfiguration struct { Authentication *operatorv1.Authentication KibanaEnabled bool + // RBACManagementEnabled is the value of the rbac-ui-config gate for this cluster. + // The access the RBAC management UI needs is rendered only while it is true, so a + // cluster that never enables the feature never carries the permissions. + RBACManagementEnabled bool + // CACertCommonName is the CommonName from the CA certificate used for operator-managed certificates. // Passed to Voltron so it can identify the correct CA issuer public key. CACertCommonName string @@ -1240,6 +1245,19 @@ func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provi return cr } +// RBACManagementEnabled reports whether the RBAC management UI is switched on for this +// cluster. The operator gates the feature's access on this, so anything short of an +// explicit, parsable true reads as disabled: a missing ConfigMap, a missing key, or a +// value the admin fat-fingered. ParseBool accepts the usual spellings ("true", "True", +// "1"), so a reasonable edit is not silently ignored. +func RBACManagementEnabled(cm *corev1.ConfigMap) bool { + if cm == nil { + return false + } + enabled, err := strconv.ParseBool(cm.Data[RBACManagementConfigMapKey]) + return err == nil && enabled +} + // RBACManagementConfigMap returns the rbac-ui-config ConfigMap seeded with the feature // disabled. Seeded on every Enterprise cluster, including ones where the feature is // never turned on, so the admin always has a switch to find. From d94b319642b37a40e74dbf0263c21f01e762f374 Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 10:47:08 -0700 Subject: [PATCH 3/8] Gate the tigera-network-admin RBAC management rules on the feature gate tigera-network-admin carries create/update/delete on clusterrolebindings and rolebindings for the RBAC management UI. Rendering that on every Enterprise cluster grants escalation-capable permissions to clusters that never switch the feature on, so add the rules only while rbac-ui-config enables it. ui-apis still writes these impersonating the caller, so the apiserver enforces escalation against the user's own permissions. EV-6816 --- pkg/render/apiserver.go | 35 +++++++++++++++++++---------------- pkg/render/apiserver_test.go | 19 ++++++++++++++----- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index f1d5181b65..ef273f70a1 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -2193,22 +2193,25 @@ func (c *apiServerComponent) tigeraNetworkAdminClusterRole() *rbacv1.ClusterRole }, }...) - // Role/binding access for the RBAC management UI. ui-apis writes these - // impersonating the caller, so the apiserver enforces escalation against the - // user's own permissions. The UI reads the role catalogue and manages group - // membership through both cluster- and namespace-scoped bindings. - rules = append(rules, - rbacv1.PolicyRule{ - APIGroups: []string{"rbac.authorization.k8s.io"}, - Resources: []string{"clusterroles", "roles"}, - Verbs: []string{"get", "list", "watch"}, - }, - rbacv1.PolicyRule{ - APIGroups: []string{"rbac.authorization.k8s.io"}, - Resources: []string{"clusterrolebindings", "rolebindings"}, - Verbs: []string{"get", "list", "watch", "create", "update", "delete"}, - }, - ) + // Role/binding access for the RBAC management UI, added only while the feature is + // switched on for this cluster. ui-apis writes these impersonating the caller, so + // the apiserver enforces escalation against the user's own permissions. The UI + // reads the role catalogue and manages group membership through both cluster- and + // namespace-scoped bindings. + if c.cfg.RBACManagementEnabled { + rules = append(rules, + rbacv1.PolicyRule{ + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterroles", "roles"}, + Verbs: []string{"get", "list", "watch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterrolebindings", "rolebindings"}, + Verbs: []string{"get", "list", "watch", "create", "update", "delete"}, + }, + ) + } // Privileges for lma.tigera.io have no effect on managed clusters. if c.cfg.ManagementClusterConnection == nil { diff --git a/pkg/render/apiserver_test.go b/pkg/render/apiserver_test.go index fd8ab89145..3d762b11d3 100644 --- a/pkg/render/apiserver_test.go +++ b/pkg/render/apiserver_test.go @@ -330,7 +330,7 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Expect(d.Spec.Template.Spec.Volumes[3].ConfigMap.Name).To(Equal("tigera-ca-bundle")) clusterRole := rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(ConsistOf(append(networkAdminPolicyRules, rbacManagementNetworkAdminRules...))) + Expect(clusterRole.Rules).To(ConsistOf(networkAdminPolicyRules)) clusterRole = rtest.GetResource(resources, "tigera-ui-user", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) Expect(clusterRole.Rules).To(ConsistOf(uiUserPolicyRules)) @@ -384,16 +384,25 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Entry("custom cluster domain", "custom-domain.internal"), ) - // Activation is decided per cluster by the rbac-ui-config ConfigMap an admin - // edits, so tigera-network-admin carries these rules on every Enterprise cluster. - It("should render the RBAC management UI rules on tigera-network-admin", func() { + // The escalation-capable rolebinding rules are the reason this is gated: a cluster + // that never switches the feature on must not carry them. + It("should gate the RBAC management UI rules on tigera-network-admin", func() { + By("omitting them while the feature gate is off") component, err := render.APIServer(cfg) Expect(err).NotTo(HaveOccurred()) resources, _ := component.Objects() clusterRole := rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) for _, rule := range rbacManagementNetworkAdminRules { - Expect(clusterRole.Rules).To(ContainElement(rule)) + Expect(clusterRole.Rules).NotTo(ContainElement(rule)) } + Expect(clusterRole.Rules).To(ConsistOf(networkAdminPolicyRules)) + + By("adding them once the admin switches the feature on") + cfg.RBACManagementEnabled = true + component, err = render.APIServer(cfg) + Expect(err).NotTo(HaveOccurred()) + resources, _ = component.Objects() + clusterRole = rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) Expect(clusterRole.Rules).To(ConsistOf(append(networkAdminPolicyRules, rbacManagementNetworkAdminRules...))) }) From 0c53a4349745e5583fb648c445dade9952163566 Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 10:52:29 -0700 Subject: [PATCH 4/8] Gate the manager RBAC management UI access on the feature gate The calico-manager cluster rules, the namespaced Role scoping ui-apis to the IdP resources, and the LDAP/AD egress all exist to serve the RBAC management UI, so render them only while rbac-ui-config enables the feature. rbacManagementUIActive folds the gate together with the multi-tenant exclusion so the cluster rules and the namespaced grant cannot drift apart. ui-apis keeps read access to the gate itself, which is how it observes the feature being switched off. EV-6816 --- pkg/render/manager.go | 19 ++++++--- pkg/render/manager_test.go | 87 +++++++++++++++++++++++--------------- 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/pkg/render/manager.go b/pkg/render/manager.go index 947944d7cd..9f749732a2 100644 --- a/pkg/render/manager.go +++ b/pkg/render/manager.go @@ -309,11 +309,11 @@ func (c *managerComponent) Objects() ([]client.Object, []client.Object) { objsToCreate = append(objsToCreate, managerClusterRoleBinding(c.cfg.Tenant, c.cfg.BindingNamespaces, c.cfg.OSSTenantNamespaces), - managerClusterRole(false, c.cfg.Installation.KubernetesProvider, c.cfg.Tenant), + managerClusterRole(false, c.cfg.Installation.KubernetesProvider, c.cfg.Tenant, c.rbacManagementUIActive()), c.managedClustersWatchRoleBinding(), ) objsToCreate = append(objsToCreate, c.managedClustersUpdateRBAC()...) - if !c.cfg.Tenant.MultiTenant() { + if c.rbacManagementUIActive() { objsToCreate = append(objsToCreate, c.rbacManagementUINamespacedRole()...) } if c.cfg.Tenant.MultiTenant() { @@ -999,7 +999,8 @@ func (c *managerComponent) managedClustersUpdateRBAC() []client.Object { } // managerClusterRole returns a clusterrole that allows authn/authz review requests. -func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provider, tenant *operatorv1.Tenant) *rbacv1.ClusterRole { +// When rbacManagementUIActive is true it also carries the RBAC management UI rules. +func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provider, tenant *operatorv1.Tenant, rbacManagementUIActive bool) *rbacv1.ClusterRole { // Different tenant types use different permission sets. name := ManagerClusterRole if tenant.ManagedClusterIsCalico() { @@ -1207,7 +1208,7 @@ func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provi // Keep this condition in sync with the rbacManagementUINamespacedRole gate; the // cluster rules and the namespaced grant are rendered together. - if !tenant.MultiTenant() { + if rbacManagementUIActive { cr.Rules = append(cr.Rules, rbacManagementUIRules()...) } @@ -1272,6 +1273,14 @@ func RBACManagementConfigMap() *corev1.ConfigMap { } } +// rbacManagementUIActive reports whether this cluster should carry the access the RBAC +// management UI needs: the admin has switched the feature on, and this is not a +// multi-tenant management cluster, where the feature is force-disabled on the ui-apis +// side and the IdP resources it reaches are pinned to calico-system. +func (c *managerComponent) rbacManagementUIActive() bool { + return c.cfg.RBACManagementEnabled && !c.cfg.Tenant.MultiTenant() +} + // rbacManagementUIRules returns the cluster-scoped rules the RBAC management // UI adds to calico-manager-role. Named-resource access is scoped separately // on rbacManagementUINamespacedRole. @@ -1416,7 +1425,7 @@ func (c *managerComponent) managerCalicoSystemNetworkPolicy() *v3.NetworkPolicy }) } - if !c.cfg.Tenant.MultiTenant() && + if c.rbacManagementUIActive() && c.cfg.Authentication != nil && c.cfg.Authentication.Spec.LDAP != nil { // LDAP/AD egress (389, 636) for the RBAC-UI directory sync, gated on LDAP // being configured on the Authentication CR. The destination is scoped to diff --git a/pkg/render/manager_test.go b/pkg/render/manager_test.go index 49e963ca4c..626cfdb229 100644 --- a/pkg/render/manager_test.go +++ b/pkg/render/manager_test.go @@ -88,8 +88,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: render.LegacyManagerNamespace}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerUserSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, @@ -487,13 +485,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Resources: []string{"clusterroles", "clusterrolebindings", "roles", "rolebindings"}, Verbs: []string{"get", "list", "watch"}, }, - // RBAC management UI: present on every non-multi-tenant cluster, since the - // feature is activated at runtime from the rbac-ui-config ConfigMap. - { - APIGroups: []string{"operator.tigera.io"}, - Resources: []string{"compliances"}, - Verbs: []string{"get"}, - }, })) roleBindingWatchManagedClusters := rtest.GetResource(resourcesToCreate, render.ManagerManagedClustersWatchRoleBindingName, "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding").(*rbacv1.ClusterRoleBinding) Expect(roleBindingWatchManagedClusters.RoleRef.Name).To(Equal(render.ManagedClustersWatchClusterRoleName)) @@ -627,8 +618,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerServiceName, Namespace: render.ManagerNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: render.LegacyManagerServiceName, Namespace: render.LegacyManagerNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerDeploymentName, Namespace: render.ManagerNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}}, @@ -848,13 +837,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Resources: []string{"clusterroles", "clusterrolebindings", "roles", "rolebindings"}, Verbs: []string{"get", "list", "watch"}, }, - // RBAC management UI: present on every non-multi-tenant cluster, since the - // feature is activated at runtime from the rbac-ui-config ConfigMap. - { - APIGroups: []string{"operator.tigera.io"}, - Resources: []string{"compliances"}, - Verbs: []string{"get"}, - }, })) }) @@ -953,8 +935,6 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerManagedClustersUpdateRBACName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: render.LegacyManagerNamespace}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerClusterSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, &v3.UISettingsGroup{ObjectMeta: metav1.ObjectMeta{Name: render.ManagerUserSettings}, TypeMeta: metav1.TypeMeta{Kind: "UISettingsGroup", APIVersion: "projectcalico.org/v3"}}, @@ -1779,15 +1759,24 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { Verbs: []string{"get", "list", "watch"}, } - // Activation is decided at runtime from the rbac-ui-config ConfigMap, so the - // access is in place on every non-multi-tenant cluster. - It("renders the namespaced RBAC UI role with read access to the feature gate", func() { + It("does not render the namespaced RBAC UI role while the feature gate is off", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, }) + Expect(rtest.GetResource(resources, render.ManagerClusterRole, render.ManagerNamespace, rbacv1.GroupName, "v1", "Role")).To(BeNil()) + }) + + It("renders the namespaced RBAC UI role with read access to the feature gate when enabled", func() { + resources, _ := renderObjects(renderConfig{ + installation: installation, + ns: render.ManagerNamespace, + rbacManagementEnabled: true, + }) role := rtest.GetResource(resources, render.ManagerClusterRole, render.ManagerNamespace, rbacv1.GroupName, "v1", "Role").(*rbacv1.Role) Expect(role.Rules).To(ContainElement(nsCreateRule)) + // ui-apis keeps read access to the gate so it can observe the admin + // switching the feature back off. Expect(role.Rules).To(ContainElement(gateReadRule)) }) @@ -1796,6 +1785,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { installation: installation, ns: "tenant-a", bindingNamespaces: []string{"tenant-a"}, + // Enabled, to prove tenancy is what excludes these and not the gate. + rbacManagementEnabled: true, tenant: &operatorv1.Tenant{ ObjectMeta: metav1.ObjectMeta{Name: "tenantA", Namespace: "tenant-a"}, Spec: operatorv1.TenantSpec{ @@ -1832,7 +1823,7 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("adds an unscoped LDAP egress when LDAP auth is configured but no host is set", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - ldapConfigured: true, + rbacManagementEnabled: true, ldapConfigured: true, }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(ldapEgress)) @@ -1841,8 +1832,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("scopes LDAP egress to a Domains match when the LDAP host is a hostname", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - ldapConfigured: true, - ldapHost: "ad.example.com:636", + rbacManagementEnabled: true, ldapConfigured: true, + ldapHost: "ad.example.com:636", }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(v3.Rule{ @@ -1860,8 +1851,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("scopes LDAP egress to a /32 Nets match when the LDAP host is an IPv4 address", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - ldapConfigured: true, - ldapHost: "10.20.30.40:389", + rbacManagementEnabled: true, ldapConfigured: true, + ldapHost: "10.20.30.40:389", }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(v3.Rule{ @@ -1879,8 +1870,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("scopes LDAP egress to a /128 Nets match when the LDAP host is an IPv6 address", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - ldapConfigured: true, - ldapHost: "[2001:db8::1]:636", + rbacManagementEnabled: true, ldapConfigured: true, + ldapHost: "[2001:db8::1]:636", }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).To(ContainElement(v3.Rule{ @@ -1898,7 +1889,16 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { It("omits LDAP egress when LDAP auth is not configured", func() { resources, _ := renderObjects(renderConfig{ installation: installation, ns: render.ManagerNamespace, - ldapConfigured: false, + rbacManagementEnabled: true, ldapConfigured: false, + }) + policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) + Expect(policy.Spec.Egress).NotTo(ContainElement(ldapEgress)) + }) + + It("omits LDAP egress when the feature gate is off, even with LDAP configured", func() { + resources, _ := renderObjects(renderConfig{ + installation: installation, ns: render.ManagerNamespace, + ldapConfigured: true, }) policy := testutils.GetCalicoSystemPolicyFromResources(policyName, resources) Expect(policy.Spec.Egress).NotTo(ContainElement(ldapEgress)) @@ -1942,8 +1942,11 @@ type renderConfig struct { externalElastic bool // ldapConfigured, when true, sets Authentication.spec.ldap (gating the RBAC-UI // LDAP egress rule); ldapHost sets Authentication.spec.ldap.host (scoping it). - ldapConfigured bool - ldapHost string + ldapConfigured bool + ldapHost string + // rbacManagementEnabled mirrors the admin's rbac-ui-config value, which gates all + // of the RBAC management UI access. + rbacManagementEnabled bool cloud bool voltronMetricsEnabled bool cloudResources render.ManagerCloudResources @@ -2019,6 +2022,7 @@ func renderObjects(roc renderConfig) ([]client.Object, []client.Object) { Manager: roc.manager, ExternalElastic: roc.externalElastic, CACertCommonName: certificateManager.CACertCommonName(), + RBACManagementEnabled: roc.rbacManagementEnabled, Cloud: roc.cloud, CloudResources: roc.cloudResources, } @@ -2041,3 +2045,20 @@ func renderObjects(roc renderConfig) ([]client.Object, []client.Object) { resourcesToCreate, resourcesToDelete := component.Objects() return resourcesToCreate, resourcesToDelete } + +// The gate is hand-edited by an admin, so the parser has to be forgiving about +// spelling and strict about everything else: anything it cannot read as an explicit +// true leaves the feature — and all of its access — switched off. +var _ = DescribeTable("RBACManagementEnabled", + func(cm *corev1.ConfigMap, expected bool) { + Expect(render.RBACManagementEnabled(cm)).To(Equal(expected)) + }, + Entry("nil ConfigMap (never seeded, or deleted)", nil, false), + Entry("missing key", &corev1.ConfigMap{Data: map[string]string{}}, false), + Entry("seeded value", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "false"}}, false), + Entry("enabled", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "true"}}, true), + Entry("enabled, capitalised", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "True"}}, true), + Entry("enabled as 1", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "1"}}, true), + Entry("unparsable value stays off", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "yes please"}}, false), + Entry("empty value stays off", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: ""}}, false), +) From 8035de24c37383d6a02326bdd17fa043ce900bdf Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 10:57:11 -0700 Subject: [PATCH 5/8] Gate the rbacsync controller on the feature gate, and skip multi-tenant The rbacsync controller and its namespaced Role are rendered only while rbac-ui-config enables the RBAC management UI. Also restores the multi-tenant exclusion. That was previously an accident of how the value was read: the installation controller looked the Manager CR up by a cluster-scoped key, which never resolves on a multi-tenant management cluster, so the feature always read as disabled there. Reading the ConfigMap removed that side effect, so rbacSyncEnabled now checks tenancy explicitly. ENABLED_CONTROLLERS is a container env var, so toggling the gate restarts calico-kube-controllers. EV-6816 --- .../kubecontrollers/kube-controllers.go | 30 +++++----- .../kubecontrollers/kube-controllers_test.go | 57 +++++++++++++------ pkg/render/render_test.go | 6 +- 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 41ab048611..c299428497 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -171,7 +171,6 @@ type KubeControllersConfiguration struct { WAFWebhookCABundle []byte // RBACManagementEnabled is the value of the rbac-ui-config gate for this cluster. - // The rbacsync controller and its access are rendered only while it is true. RBACManagementEnabled bool } @@ -228,11 +227,11 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController enabledControllers = append(enabledControllers, "applicationlayer") } - // Runs the rbacsync controller to reconcile managed ClusterRoles and bindings - // against the tigera-idp-groups ConfigMap. It reads this cluster's - // rbac-ui-config and reconciles nothing while the feature is off there. - enabledControllers = append(enabledControllers, "rbacsync") - kubeControllerRolePolicyRules = append(kubeControllerRolePolicyRules, rbacSyncControllerRules()...) + // Reconciles ClusterRoles and bindings against the tigera-idp-groups ConfigMap. + if rbacSyncEnabled(cfg) { + enabledControllers = append(enabledControllers, "rbacsync") + kubeControllerRolePolicyRules = append(kubeControllerRolePolicyRules, rbacSyncControllerRules()...) + } } return &kubeControllersComponent{ @@ -378,9 +377,7 @@ func (c *kubeControllersComponent) Objects() ([]client.Object, []client.Object) c.controllersClusterRoleBinding(), ) objectsToCreate = append(objectsToCreate, c.managedClusterRoleBindings()...) - // Keep in sync with where the rbacsync controller is enabled, in - // NewCalicoKubeControllers. - if c.kubeControllerName == KubeController && c.cfg.Installation.Variant.IsEnterprise() { + if c.kubeControllerName == KubeController && rbacSyncEnabled(c.cfg) { objectsToCreate = append(objectsToCreate, c.rbacSyncNamespacedRole()...) } @@ -715,9 +712,16 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) return rules } -// rbacSyncNamespacedRole returns the Role + RoleBinding granting rbacsync read access -// to the two ConfigMaps in calico-system it depends on: the IdP groups it reconciles -// bindings from, and the gate telling it whether to reconcile this cluster at all. +// rbacSyncEnabled reports whether the rbacsync controller and its access should be +// rendered. Multi-tenant is excluded: the feature is force-disabled on the ui-apis side. +func rbacSyncEnabled(cfg *KubeControllersConfiguration) bool { + return cfg.Installation.Variant.IsEnterprise() && + cfg.RBACManagementEnabled && + !cfg.Tenant.MultiTenant() +} + +// rbacSyncNamespacedRole returns the Role + RoleBinding granting rbacsync read access to +// the two ConfigMaps in calico-system it depends on: tigera-idp-groups and the gate. func (c *kubeControllersComponent) rbacSyncNamespacedRole() []client.Object { name := "calico-kube-controllers-rbac-sync" return []client.Object{ @@ -733,7 +737,7 @@ func (c *kubeControllersComponent) rbacSyncNamespacedRole() []client.Object { }, { // This cluster's copy of the gate; a managed cluster's is read over - // that cluster's own client. Read-only: the value is the admin's. + // that cluster's own client. APIGroups: []string{""}, Resources: []string{"configmaps"}, ResourceNames: []string{render.RBACManagementConfigMapName}, diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index b90dc4e477..eb52270857 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -261,8 +261,6 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: kubecontrollers.WASMPullSecretName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Secret"}, {name: kubecontrollers.WASMCACertName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ConfigMap"}, @@ -307,7 +305,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.ImagePullSecrets).To(ContainElement(corev1.LocalObjectReference{Name: "tigera-pull-secret"})) envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer,rbacsync", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", })) // Application-layer reconcilers consume these env vars to program WAF // EnvoyExtensionPolicy attachments. @@ -328,7 +326,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(len(dp.Spec.Template.Spec.Volumes)).To(Equal(1)) clusterRole := rtest.GetResource(resources, kubecontrollers.KubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(53), "cluster role should have 53 rules") + Expect(clusterRole.Rules).To(HaveLen(38), "cluster role should have 38 rules") // Application-layer reconciler RBAC: WAF CRDs (resources, /status, /finalizers). Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ @@ -434,8 +432,6 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: "calico-kube-controllers-endpoint-controller", ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, @@ -452,14 +448,48 @@ var _ = Describe("kube-controllers rendering tests", func() { } }) - // rbacsync runs on every Enterprise cluster and decides per cluster whether to - // reconcile by reading that cluster's rbac-ui-config ConfigMap. Context("RBAC management UI", func() { BeforeEach(func() { instance.Variant = operatorv1.CalicoEnterprise }) + It("does not enable rbacsync or grant its RBAC while the feature gate is off", func() { + component := kubecontrollers.NewCalicoKubeControllers(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + + dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) + envs := dp.Spec.Template.Spec.Containers[0].Env + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage", + })) + + Expect(rtest.GetResource(resources, "calico-kube-controllers-rbac-sync", common.CalicoNamespace, "rbac.authorization.k8s.io", "v1", "Role")).To(BeNil()) + }) + + // Multi-tenant force-disables the feature on the ui-apis side, so the controller + // must not be wired up there even with the gate on. + It("does not enable rbacsync on a multi-tenant management cluster", func() { + cfg.RBACManagementEnabled = true + cfg.Tenant = &operatorv1.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "tenantA", Namespace: "tenant-a"}, + Spec: operatorv1.TenantSpec{ID: "tenant-a"}, + } + component := kubecontrollers.NewCalicoKubeControllers(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + + dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) + for _, e := range dp.Spec.Template.Spec.Containers[0].Env { + if e.Name == "ENABLED_CONTROLLERS" { + Expect(e.Value).NotTo(ContainSubstring("rbacsync")) + } + } + Expect(rtest.GetResource(resources, "calico-kube-controllers-rbac-sync", common.CalicoNamespace, "rbac.authorization.k8s.io", "v1", "Role")).To(BeNil()) + }) + It("enables rbacsync and grants it read access to both ConfigMaps it depends on", func() { + cfg.RBACManagementEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) resources, _ := component.Objects() @@ -487,6 +517,7 @@ var _ = Describe("kube-controllers rendering tests", func() { It("does not enable rbacsync or grant its RBAC for Calico", func() { instance.Variant = operatorv1.Calico + cfg.RBACManagementEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) resources, _ := component.Objects() @@ -584,8 +615,6 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.ManagedClustersWatchRoleBindingName, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: applicationlayer.WAFWebhookServiceName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, {name: "tigera-waf.applicationlayer.projectcalico.org", ns: "", group: "admissionregistration.k8s.io", version: "v1", kind: "ValidatingWebhookConfiguration"}, @@ -618,7 +647,7 @@ var _ = Describe("kube-controllers rendering tests", func() { envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ Name: "ENABLED_CONTROLLERS", - Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer,rbacsync", + Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) @@ -639,8 +668,6 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, } @@ -782,7 +809,7 @@ var _ = Describe("kube-controllers rendering tests", func() { // Controller stays wired. Expect(c.Env).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer,rbacsync", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", })) // Told it is disabled → the reconciler de-programs rather than attaches. Expect(c.Env).To(ContainElement(corev1.EnvVar{Name: "WAF_GATEWAY_EXTENSION_ENABLED", Value: "false"})) @@ -895,8 +922,6 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerServiceAccount, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ServiceAccount"}, {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "Role"}, - {name: "calico-kube-controllers-rbac-sync", ns: common.CalicoNamespace, group: "rbac.authorization.k8s.io", version: "v1", kind: "RoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, } diff --git a/pkg/render/render_test.go b/pkg/render/render_test.go index 7839c72fd2..2b97cfaeb9 100644 --- a/pkg/render/render_test.go +++ b/pkg/render/render_test.go @@ -234,9 +234,7 @@ var _ = Describe("Rendering tests", func() { instance.NodeMetricsPort = &nodeMetricsPort c, err := allCalicoComponents(k8sServiceEp, instance, nil, nil, nil, typhaNodeTLS, nil, nil, false, "", dns.DefaultClusterDomain, 9094, 0, nil, nil) Expect(err).To(BeNil(), "Expected Calico to create successfully %s", err) - // The trailing 2 are the rbacsync Role + RoleBinding, rendered on Enterprise so - // the controller can read the ConfigMaps that drive it. - Expect(componentCount(c)).To(Equal((5 + 3 + 4 + 1 + 6 + 6 + 1 + 2) + 1 + 1 + 2)) + Expect(componentCount(c)).To(Equal((5 + 3 + 4 + 1 + 6 + 6 + 1 + 2) + 1 + 1)) }) It("should render all resources when variant is Tigera Secure and Management Cluster", func() { @@ -278,8 +276,6 @@ var _ = Describe("Rendering tests", func() { &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: common.KubeControllersDeploymentName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: common.KubeControllersDeploymentName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: kubecontrollers.ManagedClustersWatchRoleBindingName}, TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-rbac-sync", Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"}}, - &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-rbac-sync", Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}}, &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: common.KubeControllersDeploymentName, Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}}, &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "calico-kube-controllers-metrics", Namespace: common.CalicoNamespace}, TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}, From 88202847966e9de37a3b1069d0c1ca965fcffd24 Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 11:44:31 -0700 Subject: [PATCH 6/8] Leave the RBAC management UI gate entirely to the admin Review feedback: the operator should have a read-only relationship with rbac-ui-config, leaving the user in control of whether it exists. Drops the seeding. The installation controller no longer creates the ConfigMap and render.RBACManagementConfigMap goes with it, so the operator's only contact with the gate is the three controllers reading it. The read path was already fail-closed on a missing ConfigMap, so the default is unchanged. What changes is that deleting it now stays deleted rather than reappearing disabled on the next reconcile. EV-6816 --- .../apiserver/apiserver_controller.go | 8 +- .../installation/core_controller.go | 39 +------ .../installation/core_controller_test.go | 109 ++++++++++++------ pkg/controller/manager/manager_controller.go | 5 +- pkg/render/manager.go | 45 ++------ pkg/render/manager_test.go | 11 +- 6 files changed, 97 insertions(+), 120 deletions(-) diff --git a/pkg/controller/apiserver/apiserver_controller.go b/pkg/controller/apiserver/apiserver_controller.go index 487ad6852a..1301adafa7 100644 --- a/pkg/controller/apiserver/apiserver_controller.go +++ b/pkg/controller/apiserver/apiserver_controller.go @@ -104,8 +104,7 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { } if opts.EnterpriseCRDExists { - // Watched so that toggling the RBAC management UI re-renders the - // tigera-network-admin rules gated on it. + // Watched so a toggle re-renders the rules gated on it. if err = utils.AddConfigMapWatch(c, render.RBACManagementConfigMapName, common.CalicoNamespace, &handler.EnqueueRequestForObject{}); err != nil { return fmt.Errorf("apiserver-controller failed to watch ConfigMap %s: %w", render.RBACManagementConfigMapName, err) } @@ -371,9 +370,8 @@ func (r *ReconcileAPIServer) Reconcile(ctx context.Context, request reconcile.Re return reconcile.Result{}, err } - // The RBAC management UI switch the admin owns. The installation controller - // seeds it; this controller only reads it, and is watching it so a toggle - // re-renders tigera-network-admin. + // The admin owns this ConfigMap; the operator only reads it, and an absent one + // reads as disabled. gate, err := utils.GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, }, r.client) diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 84fc38decf..6f53bbbc7c 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -204,7 +204,7 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { return fmt.Errorf("tigera-installation-controller failed to watch ConfigMap %s: %w", active.ActiveConfigMapName, err) } - // Watched so that deleting the RBAC management UI feature gate re-seeds it. + // Watched so a toggle re-renders the access gated on it. if err = utils.AddConfigMapWatch(c, render.RBACManagementConfigMapName, common.CalicoNamespace, &handler.EnqueueRequestForObject{}); err != nil { return fmt.Errorf("tigera-installation-controller failed to watch ConfigMap %s: %w", render.RBACManagementConfigMapName, err) } @@ -1345,17 +1345,10 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile return reconcile.Result{}, err } - // Seeded right after the namespace it lives in, so the switch exists even on a - // cluster that is degraded for an unrelated reason. + // The admin owns this ConfigMap; the operator only reads it, and an absent one reads + // as disabled. var rbacManagementEnabled bool if instance.Spec.Variant.IsEnterprise() { - if err := r.seedRBACManagementConfigMap(ctx, reqLogger); err != nil { - r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating the RBAC management UI ConfigMap", err, reqLogger) - return reconcile.Result{}, err - } - - // Read back the switch the admin owns. The ConfigMap is watched, so flipping it - // re-runs this reconcile and the access below follows the new value. gate, err := utils.GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, }, r.client) @@ -2395,32 +2388,6 @@ func (r *ReconcileInstallation) checkActive(log logr.Logger) (*corev1.ConfigMap, } } -// seedRBACManagementConfigMap creates the rbac-ui-config ConfigMap, disabled, if it is -// not already present. This controller owns it because every cluster needs one in order -// to control its own access to the RBAC management UI. -// -// Create-only and ownerless, so an admin's toggle survives both reconciles and -// Installation deletion. Deleting the ConfigMap is fail-closed: consumers read a missing -// one as disabled and this re-seeds it disabled, since the previous value is not tracked. -func (r *ReconcileInstallation) seedRBACManagementConfigMap(ctx context.Context, log logr.Logger) error { - cm := render.RBACManagementConfigMap() - - handler := r.newComponentHandler(log, r.client, r.scheme, nil) - handler.SetCreateOnly() - err := handler.CreateOrUpdateOrDelete(ctx, render.NewCreationPassthrough(cm), nil) - if err != nil { - if apierrors.IsAlreadyExists(err) { - // The value is the admin's, so there is nothing to do. - return nil - } - return err - } - - log.Info("Seeded the RBAC management UI feature gate, disabled", - "configMap", fmt.Sprintf("%s/%s", cm.Namespace, cm.Name), "key", render.RBACManagementConfigMapKey) - return nil -} - func (r *ReconcileInstallation) updateCRDs(ctx context.Context, variant operatorv1.ProductVariant, log logr.Logger) error { if !r.manageCRDs { return nil diff --git a/pkg/controller/installation/core_controller_test.go b/pkg/controller/installation/core_controller_test.go index 71dab29e5d..81d3c97524 100644 --- a/pkg/controller/installation/core_controller_test.go +++ b/pkg/controller/installation/core_controller_test.go @@ -1382,17 +1382,32 @@ var _ = Describe("Testing core-controller installation", func() { )) }) - // The RBAC management UI is Calico Enterprise only. - It("should not seed the RBAC management UI feature gate for Calico", func() { + // The RBAC management UI is Calico Enterprise only, so the gate is not even read + // on Calico — an admin who creates one there gets nothing. + It("should ignore the RBAC management UI feature gate for Calico", func() { cr.Spec.Variant = operator.Calico Expect(c.Create(ctx, cr)).NotTo(HaveOccurred()) + Expect(c.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: render.RBACManagementConfigMapName, + Namespace: common.CalicoNamespace, + }, + Data: map[string]string{render.RBACManagementConfigMapKey: "true"}, + })).NotTo(HaveOccurred()) + _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) - err = c.Get(ctx, client.ObjectKey{ - Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, - }, &corev1.ConfigMap{}) - Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected no rbac-ui-config on a Calico cluster") + d := &appsv1.Deployment{} + Expect(c.Get(ctx, client.ObjectKey{ + Name: "calico-kube-controllers", Namespace: common.CalicoNamespace, + }, d)).ShouldNot(HaveOccurred()) + container := test.GetContainer(d.Spec.Template.Spec.Containers, "calico-kube-controllers") + Expect(container).NotTo(BeNil()) + Expect(container.Env).NotTo(ContainElement(WithTransform( + func(env corev1.EnvVar) string { return env.Value }, + ContainSubstring("rbacsync"), + ))) }) It("should Reconcile with default config", func() { @@ -2416,44 +2431,66 @@ var _ = Describe("Testing core-controller installation", func() { Expect(secret.GetOwnerReferences()).To(HaveLen(1)) }) - // The operator seeds the switch disabled; the admin owns the value from then - // on, so it must never be overwritten or garbage-collected. + // The admin owns the gate ConfigMap outright: whether it exists at all, and what + // it says. The operator only ever reads it, and follows whatever it finds. Context("RBAC management UI feature gate", func() { gateKey := client.ObjectKey{Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace} - It("seeds the ConfigMap disabled, without an owner reference", func() { + // enabledControllers returns the ENABLED_CONTROLLERS the reconcile handed + // kube-controllers, which is where the gate's value is observable. + enabledControllers := func() string { + d := &appsv1.Deployment{} + Expect(c.Get(ctx, client.ObjectKey{ + Name: "calico-kube-controllers", Namespace: common.CalicoNamespace, + }, d)).ShouldNot(HaveOccurred()) + + container := test.GetContainer(d.Spec.Template.Spec.Containers, "calico-kube-controllers") + Expect(container).NotTo(BeNil()) + for _, env := range container.Env { + if env.Name == "ENABLED_CONTROLLERS" { + return env.Value + } + } + Fail("calico-kube-controllers has no ENABLED_CONTROLLERS env var") + return "" + } + + writeGate := func(value string) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: gateKey.Name, Namespace: gateKey.Namespace}, + Data: map[string]string{render.RBACManagementConfigMapKey: value}, + } + Expect(c.Create(ctx, cm)).ShouldNot(HaveOccurred()) + } + + It("does not create the ConfigMap", func() { _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) - cm := &corev1.ConfigMap{} - Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) - Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "false")) - // Deleting the Installation must not take the admin's toggle with it. - Expect(cm.GetOwnerReferences()).To(BeEmpty()) + // Its existence is the admin's signal, so the operator must leave the + // cluster without one until they say otherwise. + err = c.Get(ctx, gateKey, &corev1.ConfigMap{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected the operator not to create rbac-ui-config") }) - It("preserves an admin's enabled value across reconciles", func() { + It("reads a missing ConfigMap as disabled", func() { _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) - cm := &corev1.ConfigMap{} - Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) - cm.Data[render.RBACManagementConfigMapKey] = "true" - Expect(c.Update(ctx, cm)).ShouldNot(HaveOccurred()) + Expect(enabledControllers()).NotTo(ContainSubstring("rbacsync")) + }) - _, err = r.Reconcile(ctx, reconcile.Request{}) + It("follows the admin's value once they create the ConfigMap", func() { + writeGate("true") + + _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) - Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) - Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "true")) + Expect(enabledControllers()).To(ContainSubstring("rbacsync")) }) - It("does not overwrite a ConfigMap that already exists when the operator first sees it", func() { - // Created out-of-band (an admin, or the e2e harness) before the - // operator first reconciles. - preexisting := render.RBACManagementConfigMap() - preexisting.Data[render.RBACManagementConfigMapKey] = "true" - Expect(c.Create(ctx, preexisting)).ShouldNot(HaveOccurred()) + It("leaves the admin's value untouched across reconciles", func() { + writeGate("true") _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) @@ -2461,23 +2498,27 @@ var _ = Describe("Testing core-controller installation", func() { cm := &corev1.ConfigMap{} Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "true")) + // No owner reference either: deleting the Installation must not take the + // admin's toggle with it. + Expect(cm.GetOwnerReferences()).To(BeEmpty()) }) - It("re-seeds the ConfigMap disabled after it is deleted", func() { + It("switches the feature back off when the admin deletes the ConfigMap", func() { + writeGate("true") + _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) + Expect(enabledControllers()).To(ContainSubstring("rbacsync")) cm := &corev1.ConfigMap{} Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) Expect(c.Delete(ctx, cm)).ShouldNot(HaveOccurred()) - // Fail-closed: the switch comes back off; the operator does not track - // the previous value. + // Fail-closed, and the operator does not put the ConfigMap back. _, err = r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) - - Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) - Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "false")) + Expect(enabledControllers()).NotTo(ContainSubstring("rbacsync")) + Expect(apierrors.IsNotFound(c.Get(ctx, gateKey, cm))).To(BeTrue()) }) }) }) diff --git a/pkg/controller/manager/manager_controller.go b/pkg/controller/manager/manager_controller.go index 2df55edac7..cc2d2d0061 100644 --- a/pkg/controller/manager/manager_controller.go +++ b/pkg/controller/manager/manager_controller.go @@ -711,9 +711,8 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ } } - // The RBAC management UI switch the admin owns. The installation controller seeds - // it; this controller only reads it, and is watching it so a toggle re-renders the - // access gated on it. + // The admin owns this ConfigMap; the operator only reads it, and an absent one reads + // as disabled. rbacGate, err := utils.GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace, }, r.client) diff --git a/pkg/render/manager.go b/pkg/render/manager.go index 9f749732a2..82553ddc6a 100644 --- a/pkg/render/manager.go +++ b/pkg/render/manager.go @@ -83,11 +83,8 @@ const ( // Keep in sync with ui-apis rbacmanagement/idp LDAPConfigSecretName. RBACManagementLDAPConfigSecretName = "tigera-idp-ldap-config" - // The rbac-ui-config ConfigMap is the whole configuration surface for the RBAC - // management UI: the operator seeds it disabled per cluster, an admin toggles the - // value, and ui-apis and rbacsync read it live so a toggle takes effect without - // rolling any workload. The value is parsed as a boolean; missing or unparsable - // reads as disabled. Keep in sync with ui-apis rbacmanagement/gate. + // The admin-owned switch for the RBAC management UI, read by the operator, ui-apis + // and rbacsync. Keep in sync with ui-apis rbacmanagement/gate. RBACManagementConfigMapName = "rbac-ui-config" RBACManagementConfigMapKey = "rbac-ui-enabled" @@ -230,8 +227,6 @@ type ManagerConfiguration struct { KibanaEnabled bool // RBACManagementEnabled is the value of the rbac-ui-config gate for this cluster. - // The access the RBAC management UI needs is rendered only while it is true, so a - // cluster that never enables the feature never carries the permissions. RBACManagementEnabled bool // CACertCommonName is the CommonName from the CA certificate used for operator-managed certificates. @@ -1206,8 +1201,7 @@ func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provi }, } - // Keep this condition in sync with the rbacManagementUINamespacedRole gate; the - // cluster rules and the namespaced grant are rendered together. + // Keep in sync with the rbacManagementUINamespacedRole gate. if rbacManagementUIActive { cr.Rules = append(cr.Rules, rbacManagementUIRules()...) } @@ -1247,10 +1241,7 @@ func managerClusterRole(managedCluster bool, kubernetesProvider operatorv1.Provi } // RBACManagementEnabled reports whether the RBAC management UI is switched on for this -// cluster. The operator gates the feature's access on this, so anything short of an -// explicit, parsable true reads as disabled: a missing ConfigMap, a missing key, or a -// value the admin fat-fingered. ParseBool accepts the usual spellings ("true", "True", -// "1"), so a reasonable edit is not silently ignored. +// cluster. A missing ConfigMap, missing key or unparsable value reads as disabled. func RBACManagementEnabled(cm *corev1.ConfigMap) bool { if cm == nil { return false @@ -1259,24 +1250,8 @@ func RBACManagementEnabled(cm *corev1.ConfigMap) bool { return err == nil && enabled } -// RBACManagementConfigMap returns the rbac-ui-config ConfigMap seeded with the feature -// disabled. Seeded on every Enterprise cluster, including ones where the feature is -// never turned on, so the admin always has a switch to find. -func RBACManagementConfigMap() *corev1.ConfigMap { - return &corev1.ConfigMap{ - TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{ - Name: RBACManagementConfigMapName, - Namespace: common.CalicoNamespace, - }, - Data: map[string]string{RBACManagementConfigMapKey: "false"}, - } -} - -// rbacManagementUIActive reports whether this cluster should carry the access the RBAC -// management UI needs: the admin has switched the feature on, and this is not a -// multi-tenant management cluster, where the feature is force-disabled on the ui-apis -// side and the IdP resources it reaches are pinned to calico-system. +// rbacManagementUIActive reports whether this cluster should carry the RBAC management +// UI access. Multi-tenant is excluded: the feature is force-disabled on the ui-apis side. func (c *managerComponent) rbacManagementUIActive() bool { return c.cfg.RBACManagementEnabled && !c.cfg.Tenant.MultiTenant() } @@ -1307,9 +1282,8 @@ func (c *managerComponent) rbacManagementUINamespacedRole() []client.Object { ObjectMeta: metav1.ObjectMeta{Name: ManagerClusterRole, Namespace: common.CalicoNamespace}, Rules: []rbacv1.PolicyRule{ { - // create carries the object name in the request body, not the - // URL path, so RBAC cannot restrict it by resource name; it is - // scoped to this namespace instead. + // create cannot be restricted by resource name, so it is scoped to + // this namespace instead. APIGroups: []string{""}, Resources: []string{"configmaps", "secrets"}, Verbs: []string{"create"}, @@ -1327,8 +1301,7 @@ func (c *managerComponent) rbacManagementUINamespacedRole() []client.Object { Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, }, { - // The gate ui-apis watches to decide whether the feature is active - // on this cluster. Read-only: the value is the admin's to set. + // The gate ui-apis watches; read-only, the value is the admin's. APIGroups: []string{""}, Resources: []string{"configmaps"}, ResourceNames: []string{RBACManagementConfigMapName}, diff --git a/pkg/render/manager_test.go b/pkg/render/manager_test.go index 626cfdb229..6343b3ebd1 100644 --- a/pkg/render/manager_test.go +++ b/pkg/render/manager_test.go @@ -1743,8 +1743,8 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { } }) - // The create rule on ConfigMaps/Secrets is unique to this Role, so it - // identifies the RBAC management UI access without matching the whole rule set. + // The create rule is unique to this Role, so it identifies the access without + // matching the whole rule set. nsCreateRule := rbacv1.PolicyRule{ APIGroups: []string{""}, Resources: []string{"configmaps", "secrets"}, @@ -1944,8 +1944,7 @@ type renderConfig struct { // LDAP egress rule); ldapHost sets Authentication.spec.ldap.host (scoping it). ldapConfigured bool ldapHost string - // rbacManagementEnabled mirrors the admin's rbac-ui-config value, which gates all - // of the RBAC management UI access. + // rbacManagementEnabled mirrors the admin's rbac-ui-config value. rbacManagementEnabled bool cloud bool voltronMetricsEnabled bool @@ -2053,9 +2052,9 @@ var _ = DescribeTable("RBACManagementEnabled", func(cm *corev1.ConfigMap, expected bool) { Expect(render.RBACManagementEnabled(cm)).To(Equal(expected)) }, - Entry("nil ConfigMap (never seeded, or deleted)", nil, false), + Entry("nil ConfigMap (never created, or deleted)", nil, false), Entry("missing key", &corev1.ConfigMap{Data: map[string]string{}}, false), - Entry("seeded value", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "false"}}, false), + Entry("explicitly disabled", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "false"}}, false), Entry("enabled", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "true"}}, true), Entry("enabled, capitalised", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "True"}}, true), Entry("enabled as 1", &corev1.ConfigMap{Data: map[string]string{render.RBACManagementConfigMapKey: "1"}}, true), From fe59bcf7fffbd463dbb6e0c08d74bfc26729054b Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 14:34:36 -0700 Subject: [PATCH 7/8] Cover the gate read paths in all three controllers The manager and apiserver reads had no test: the render tests set RBACManagementEnabled by hand, so nothing asserted either controller plumbs the ConfigMap through to its render config. Adds specs per controller that reconcile and assert on the rendered output -- the gated tigera-network-admin rules, and the namespaced IdP Role -- for the ConfigMap being absent, enabled and explicitly false. Also covers an unreadable ConfigMap degrading rather than rendering as disabled, and the gate being read on a managed cluster. EV-6816 --- .../apiserver/apiserver_controller_test.go | 121 ++++++++++++++++++ .../installation/core_controller_test.go | 45 +++++-- .../manager/manager_controller_test.go | 72 +++++++++++ 3 files changed, 228 insertions(+), 10 deletions(-) diff --git a/pkg/controller/apiserver/apiserver_controller_test.go b/pkg/controller/apiserver/apiserver_controller_test.go index d192103173..472d7fc99c 100644 --- a/pkg/controller/apiserver/apiserver_controller_test.go +++ b/pkg/controller/apiserver/apiserver_controller_test.go @@ -33,6 +33,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/reconcile" v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" @@ -491,6 +492,126 @@ var _ = Describe("apiserver controller tests", func() { }) }) + // These cover the controller's half: reading the ConfigMap and handing the value to + // the renderer. + Context("RBAC management UI feature gate", func() { + // gatedRule is where the gate's value is observable in the rendered output. + gatedRule := rbacv1.PolicyRule{ + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterrolebindings", "rolebindings"}, + Verbs: []string{"get", "list", "watch", "create", "update", "delete"}, + } + + writeGate := func(value string) { + Expect(cli.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: render.RBACManagementConfigMapName, + Namespace: common.CalicoNamespace, + }, + Data: map[string]string{render.RBACManagementConfigMapKey: value}, + })).NotTo(HaveOccurred()) + } + + // networkAdminRules reconciles and returns the rendered rules. + networkAdminRules := func() []rbacv1.PolicyRule { + r := ReconcileAPIServer{ + client: cli, + scheme: scheme, + status: mockStatus, + tierWatchReady: ready, + migrationWatchReady: &utils.ReadyFlag{}, + opts: options.ControllerOptions{ + EnterpriseCRDExists: true, + DetectedProvider: operatorv1.ProviderNone, + }, + } + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + cr := rbacv1.ClusterRole{} + Expect(cli.Get(ctx, client.ObjectKey{Name: "tigera-network-admin"}, &cr)).NotTo(HaveOccurred()) + return cr.Rules + } + + BeforeEach(func() { + Expect(cli.Create(ctx, installation)).NotTo(HaveOccurred()) + }) + + It("withholds the rules when the admin has not created the ConfigMap", func() { + Expect(networkAdminRules()).NotTo(ContainElement(gatedRule)) + }) + + It("adds the rules once the admin enables the feature", func() { + writeGate("true") + Expect(networkAdminRules()).To(ContainElement(gatedRule)) + }) + + It("withholds the rules when the admin sets the value to false", func() { + writeGate("false") + Expect(networkAdminRules()).NotTo(ContainElement(gatedRule)) + }) + + // A managed cluster carries tigera-network-admin too, so the read must not be + // skipped there. + It("reads the gate on a managed cluster", func() { + Expect(cli.Create(ctx, &operatorv1.ManagementClusterConnection{ + ObjectMeta: metav1.ObjectMeta{Name: utils.DefaultEnterpriseInstanceKey.Name}, + })).NotTo(HaveOccurred()) + writeGate("true") + + Expect(networkAdminRules()).To(ContainElement(gatedRule)) + }) + + // An unreadable ConfigMap is unknown state, not absent, so it degrades rather + // than rendering as disabled. + It("degrades and requeues when the ConfigMap cannot be read", func() { + readErr := fmt.Errorf("the API server is having a bad day") + failing := ctrlrfake.DefaultFakeClientBuilder(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == render.RBACManagementConfigMapName { + return readErr + } + return c.Get(ctx, key, obj, opts...) + }, + }).Build() + + // Re-plant what the reconcile needs on the new client. + certificateManager, err := certificatemanager.Create(failing, nil, "cluster.local", common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + Expect(failing.Create(ctx, certificateManager.KeyPair().Secret(common.OperatorNamespace()))).NotTo(HaveOccurred()) + Expect(failing.Create(ctx, &operatorv1.APIServer{ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}})).NotTo(HaveOccurred()) + Expect(failing.Create(ctx, &v3.Tier{ObjectMeta: metav1.ObjectMeta{Name: "calico-system"}})).NotTo(HaveOccurred()) + // The shared installation carries a resourceVersion that Create would reject. + freshInstallation := installation.DeepCopy() + freshInstallation.ResourceVersion = "" + Expect(failing.Create(ctx, freshInstallation)).NotTo(HaveOccurred()) + + degraded := &status.MockStatus{} + degraded.On("OnCRFound").Return() + degraded.On("SetMetaData", mock.Anything).Return() + degraded.On("AddCertificateSigningRequests", mock.Anything).Return().Maybe() + degraded.On("RemoveCertificateSigningRequests", mock.Anything).Return().Maybe() + degraded.On("SetDegraded", operatorv1.ResourceReadError, + "Error reading the RBAC management UI ConfigMap", readErr.Error(), mock.Anything).Return().Once() + + r := ReconcileAPIServer{ + client: failing, + scheme: scheme, + status: degraded, + tierWatchReady: ready, + migrationWatchReady: &utils.ReadyFlag{}, + opts: options.ControllerOptions{ + EnterpriseCRDExists: true, + DetectedProvider: operatorv1.ProviderNone, + }, + } + _, err = r.Reconcile(ctx, reconcile.Request{}) + Expect(err).To(MatchError(readErr)) + degraded.AssertExpectations(GinkgoT()) + }) + }) + Context("Reconcile for Condition status", func() { generation := int64(2) BeforeEach(func() { diff --git a/pkg/controller/installation/core_controller_test.go b/pkg/controller/installation/core_controller_test.go index 81d3c97524..80c354c664 100644 --- a/pkg/controller/installation/core_controller_test.go +++ b/pkg/controller/installation/core_controller_test.go @@ -1382,8 +1382,7 @@ var _ = Describe("Testing core-controller installation", func() { )) }) - // The RBAC management UI is Calico Enterprise only, so the gate is not even read - // on Calico — an admin who creates one there gets nothing. + // The feature is Enterprise only, so the gate is not read on Calico. It("should ignore the RBAC management UI feature gate for Calico", func() { cr.Spec.Variant = operator.Calico Expect(c.Create(ctx, cr)).NotTo(HaveOccurred()) @@ -2431,13 +2430,12 @@ var _ = Describe("Testing core-controller installation", func() { Expect(secret.GetOwnerReferences()).To(HaveLen(1)) }) - // The admin owns the gate ConfigMap outright: whether it exists at all, and what - // it says. The operator only ever reads it, and follows whatever it finds. + // The admin owns whether the ConfigMap exists and what it says; the operator only + // reads it. Context("RBAC management UI feature gate", func() { gateKey := client.ObjectKey{Name: render.RBACManagementConfigMapName, Namespace: common.CalicoNamespace} - // enabledControllers returns the ENABLED_CONTROLLERS the reconcile handed - // kube-controllers, which is where the gate's value is observable. + // enabledControllers is where the gate's value is observable. enabledControllers := func() string { d := &appsv1.Deployment{} Expect(c.Get(ctx, client.ObjectKey{ @@ -2467,8 +2465,6 @@ var _ = Describe("Testing core-controller installation", func() { _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) - // Its existence is the admin's signal, so the operator must leave the - // cluster without one until they say otherwise. err = c.Get(ctx, gateKey, &corev1.ConfigMap{}) Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected the operator not to create rbac-ui-config") }) @@ -2498,8 +2494,7 @@ var _ = Describe("Testing core-controller installation", func() { cm := &corev1.ConfigMap{} Expect(c.Get(ctx, gateKey, cm)).ShouldNot(HaveOccurred()) Expect(cm.Data).To(HaveKeyWithValue(render.RBACManagementConfigMapKey, "true")) - // No owner reference either: deleting the Installation must not take the - // admin's toggle with it. + // Deleting the Installation must not take the admin's toggle with it. Expect(cm.GetOwnerReferences()).To(BeEmpty()) }) @@ -2520,6 +2515,22 @@ var _ = Describe("Testing core-controller installation", func() { Expect(enabledControllers()).NotTo(ContainSubstring("rbacsync")) Expect(apierrors.IsNotFound(c.Get(ctx, gateKey, cm))).To(BeTrue()) }) + + // An unreadable ConfigMap is unknown state, not absent, so it degrades rather + // than rendering as disabled. + It("degrades and requeues when the ConfigMap cannot be read", func() { + readErr := fmt.Errorf("the API server is having a bad day") + r.client = failingGateReadClient{Client: c, err: readErr} + mockStatus.On("SetDegraded", operator.ResourceReadError, + "Error reading the RBAC management UI ConfigMap", readErr.Error(), mock.Anything).Return().Once() + + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).To(MatchError(readErr)) + // The shared mockStatus expects a full reconcile, which this returns early + // from, so assert the one call. + mockStatus.AssertCalled(GinkgoT(), "SetDegraded", operator.ResourceReadError, + "Error reading the RBAC management UI ConfigMap", readErr.Error(), mock.Anything) + }) }) }) @@ -3200,3 +3211,17 @@ var _ = Describe("updateValidatingAdmissionPolicies", func() { Expect(componentHandler.objectsToCreate).To(HaveLen(2)) }) }) + +// failingGateReadClient fails the read of the gate ConfigMap and passes everything else +// through, to distinguish an unreadable ConfigMap from an absent one. +type failingGateReadClient struct { + client.Client + err error +} + +func (f failingGateReadClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == render.RBACManagementConfigMapName { + return f.err + } + return f.Client.Get(ctx, key, obj, opts...) +} diff --git a/pkg/controller/manager/manager_controller_test.go b/pkg/controller/manager/manager_controller_test.go index b3960fa4c7..ba8a82fb2b 100644 --- a/pkg/controller/manager/manager_controller_test.go +++ b/pkg/controller/manager/manager_controller_test.go @@ -584,6 +584,64 @@ var _ = Describe("Manager controller tests", func() { r.tierWatchReady.MarkAsReady() }) + // These cover the controller's half: reading the ConfigMap and handing the + // value to the renderer. The namespaced Role is where it is observable. + Context("RBAC management UI feature gate", func() { + roleKey := client.ObjectKey{Name: render.ManagerClusterRole, Namespace: common.CalicoNamespace} + + writeGate := func(value string) { + Expect(c.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: render.RBACManagementConfigMapName, + Namespace: common.CalicoNamespace, + }, + Data: map[string]string{render.RBACManagementConfigMapKey: value}, + })).NotTo(HaveOccurred()) + } + + // idpRoleExists reconciles and reports whether the gated Role landed. + idpRoleExists := func() bool { + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + err = c.Get(ctx, roleKey, &rbacv1.Role{}) + if err != nil && !kerror.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + return err == nil + } + + It("withholds the namespaced Role when the admin has not created the ConfigMap", func() { + Expect(idpRoleExists()).To(BeFalse()) + }) + + It("renders the namespaced Role once the admin enables the feature", func() { + writeGate("true") + Expect(idpRoleExists()).To(BeTrue()) + }) + + It("withholds the namespaced Role when the admin sets the value to false", func() { + writeGate("false") + Expect(idpRoleExists()).To(BeFalse()) + }) + + // An unreadable ConfigMap is unknown state, not absent, so it degrades + // rather than rendering as disabled. + It("degrades and requeues when the ConfigMap cannot be read", func() { + readErr := fmt.Errorf("the API server is having a bad day") + r.client = failingGateReadClient{Client: c, err: readErr} + // The shared mockStatus expects a full reconcile, which this returns + // early from, so assert the one call. + mockStatus.On("SetDegraded", operatorv1.ResourceReadError, + "Error reading the RBAC management UI ConfigMap", readErr.Error(), mock.Anything).Return().Once() + + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).To(MatchError(readErr)) + mockStatus.AssertCalled(GinkgoT(), "SetDegraded", operatorv1.ResourceReadError, + "Error reading the RBAC management UI ConfigMap", readErr.Error(), mock.Anything) + }) + }) + It("should reconcile legacy manager namespace", func() { result, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).NotTo(HaveOccurred()) @@ -1479,6 +1537,20 @@ var _ = Describe("Manager controller tests", func() { }) }) +// failingGateReadClient fails the read of the gate ConfigMap and passes everything else +// through, to distinguish an unreadable ConfigMap from an absent one. +type failingGateReadClient struct { + client.Client + err error +} + +func (f failingGateReadClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == render.RBACManagementConfigMapName { + return f.err + } + return f.Client.Get(ctx, key, obj, opts...) +} + func assertSANs(secret *corev1.Secret, expectedSAN string) { var cert *x509.Certificate From 570c7e432041d1d0cda33f4db19892eb328d01a4 Mon Sep 17 00:00:00 2001 From: Dimitri Nicolopoulos Date: Thu, 30 Jul 2026 15:03:29 -0700 Subject: [PATCH 8/8] Let a network admin write the RBAC management UI switch Nothing granted write access to rbac-ui-config, so enabling the RBAC management UI needed cluster-admin. Since the operator no longer creates the ConfigMap, an admin with only tigera-network-admin could not switch the feature on at all. Adds the write access to tigera-network-admin, ungated: a rule rendered only while the feature is on could never be used to turn it on. create cannot be restricted by resource name, so this grant admits creating any ConfigMap in the namespace. Narrowing it would need a namespaced Role, which is not available here -- tigera-network-admin is a ClusterRole the customer binds themselves, so an operator-rendered Role would have no subject to bind to. EV-6816 --- pkg/render/apiserver.go | 27 ++++++++++++++------ pkg/render/apiserver_test.go | 49 ++++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index ef273f70a1..12ca5bab45 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -159,8 +159,6 @@ type APIServerConfiguration struct { Cloud bool // RBACManagementEnabled is the value of the rbac-ui-config gate for this cluster. - // The escalation-capable RBAC management UI rules are added to - // tigera-network-admin only while it is true. RBACManagementEnabled bool // Whether or not we should run the aggregation API server for projectcalico.org/v3 APIs @@ -2193,11 +2191,26 @@ func (c *apiServerComponent) tigeraNetworkAdminClusterRole() *rbacv1.ClusterRole }, }...) - // Role/binding access for the RBAC management UI, added only while the feature is - // switched on for this cluster. ui-apis writes these impersonating the caller, so - // the apiserver enforces escalation against the user's own permissions. The UI - // reads the role catalogue and manages group membership through both cluster- and - // namespace-scoped bindings. + // Write access to the switch, so a network admin can enable the feature without + // cluster-admin. Not gated: a rule rendered only while the feature is on could never + // be used to turn it on. create cannot be restricted by resource name, so it admits + // creating any ConfigMap in the namespace. + rules = append(rules, + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"create"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, + }, + ) + + // Role/binding access for the RBAC management UI. ui-apis writes these impersonating + // the caller, so the apiserver enforces escalation against the user's own permissions. if c.cfg.RBACManagementEnabled { rules = append(rules, rbacv1.PolicyRule{ diff --git a/pkg/render/apiserver_test.go b/pkg/render/apiserver_test.go index 3d762b11d3..049407fdbb 100644 --- a/pkg/render/apiserver_test.go +++ b/pkg/render/apiserver_test.go @@ -384,8 +384,7 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Entry("custom cluster domain", "custom-domain.internal"), ) - // The escalation-capable rolebinding rules are the reason this is gated: a cluster - // that never switches the feature on must not carry them. + // The escalation-capable rolebinding rules are the reason this is gated. It("should gate the RBAC management UI rules on tigera-network-admin", func() { By("omitting them while the feature gate is off") component, err := render.APIServer(cfg) @@ -406,6 +405,35 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { Expect(clusterRole.Rules).To(ConsistOf(append(networkAdminPolicyRules, rbacManagementNetworkAdminRules...))) }) + // Not gated: a rule rendered only while the feature is on could never turn it on. + It("should grant tigera-network-admin write access to the switch regardless of the gate", func() { + gateWriteRules := []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{render.RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, + }, + } + + for _, enabled := range []bool{false, true} { + cfg.RBACManagementEnabled = enabled + component, err := render.APIServer(cfg) + Expect(err).NotTo(HaveOccurred()) + resources, _ := component.Objects() + clusterRole := rtest.GetResource(resources, "tigera-network-admin", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + for _, rule := range gateWriteRules { + Expect(clusterRole.Rules).To(ContainElement(rule), + "expected the switch write rules with RBACManagementEnabled=%v", enabled) + } + } + }) + It("should render resources without an aggregation server", func() { cfg.RequiresAggregationServer = false @@ -1975,11 +2003,22 @@ var ( ResourceNames: []string{"webhooks-secret"}, Verbs: []string{"patch"}, }, + // Write access to the switch, ungated so it can be used to turn the feature on. + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"create"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + ResourceNames: []string{render.RBACManagementConfigMapName}, + Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, + }, } - // rbacManagementNetworkAdminRules are the extra tigera-network-admin rules - // added when rbac.ui is Enabled. See tigeraNetworkAdminClusterRole for the - // rationale behind the verb set. + // rbacManagementNetworkAdminRules are the extra tigera-network-admin rules added + // while the feature is enabled. rbacManagementNetworkAdminRules = []rbacv1.PolicyRule{ { APIGroups: []string{"rbac.authorization.k8s.io"},