From a0ce1666bbca739b0b558cab5b3072a260bc00eb Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Mon, 10 Aug 2026 17:23:56 +0100 Subject: [PATCH] feat: gate the WAF management UI on the waf-ui-config ConfigMap The WAF management UI is gated per cluster by the waf-ui-config ConfigMap in calico-system, keyed waf-ui-enabled. An admin creates and edits it; the operator only reads it. A missing ConfigMap, a missing key or an unparsable value all read as disabled, so the feature stays off until an admin turns it on and deleting the switch turns it back off. Unlike the RBAC gate, ui-apis does not watch this ConfigMap. It reads WAF_UI_ENABLED at startup, so the operator projects the admin's value onto the container and a toggle rolls the manager Deployment. tigera-network-admin gets write access to the switch alongside the RBAC one, ungated, since a rule rendered only while the feature is on could never be used to turn it on. This controls UI visibility only. WAF enforcement on traffic is still configured through GatewayAPI.spec.extensions.waf. EV-6793 --- pkg/controller/manager/manager_controller.go | 13 +++ .../manager/manager_controller_test.go | 79 +++++++++++++++++-- pkg/controller/utils/utils.go | 18 +++++ pkg/render/apiserver.go | 5 +- pkg/render/apiserver_test.go | 7 +- pkg/render/common/wafmanagement/gate.go | 41 ++++++++++ pkg/render/common/wafmanagement/gate_test.go | 41 ++++++++++ .../wafmanagement/wafmanagement_suite_test.go | 29 +++++++ pkg/render/manager.go | 5 ++ pkg/render/manager_test.go | 34 ++++++++ 10 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 pkg/render/common/wafmanagement/gate.go create mode 100644 pkg/render/common/wafmanagement/gate_test.go create mode 100644 pkg/render/common/wafmanagement/wafmanagement_suite_test.go diff --git a/pkg/controller/manager/manager_controller.go b/pkg/controller/manager/manager_controller.go index 95d3f6865a..1e89b3ca9c 100644 --- a/pkg/controller/manager/manager_controller.go +++ b/pkg/controller/manager/manager_controller.go @@ -57,6 +57,7 @@ import ( relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" "github.com/tigera/operator/pkg/render/common/networkpolicy" "github.com/tigera/operator/pkg/render/common/rbacmanagement" + "github.com/tigera/operator/pkg/render/common/wafmanagement" rgateway "github.com/tigera/operator/pkg/render/gateway" "github.com/tigera/operator/pkg/render/logstorage/eck" rmanager "github.com/tigera/operator/pkg/render/manager" @@ -232,6 +233,11 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { return fmt.Errorf("manager-controller failed to watch ConfigMap resource %s: %w", rbacmanagement.ConfigMapName, err) } + // Watched so that toggling the WAF management UI rolls ui-apis with the new WAF_UI_ENABLED. + if err = utils.AddConfigMapWatch(c, wafmanagement.ConfigMapName, common.CalicoNamespace, eventHandler); err != nil { + return fmt.Errorf("manager-controller failed to watch ConfigMap resource %s: %w", wafmanagement.ConfigMapName, err) + } + if err = utils.AddConfigMapWatch(c, relasticsearch.ClusterConfigConfigMapName, common.OperatorNamespace(), eventHandler); err != nil { return fmt.Errorf("manager-controller failed to watch the ConfigMap resource: %w", err) } @@ -738,6 +744,12 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ return reconcile.Result{}, err } + wafManagementEnabled, err := utils.WAFManagementEnabled(ctx, r.client, installationSpec.Variant, tenant.MultiTenant()) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading the WAF management UI ConfigMap", err, logc) + return reconcile.Result{}, err + } + managerCfg := &render.ManagerConfiguration{ VoltronRouteConfig: routeConfig, KeyValidatorConfig: keyValidatorConfig, @@ -765,6 +777,7 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ Authentication: authenticationCR, KibanaEnabled: kibanaEnabled, RBACManagementEnabled: rbacManagementEnabled, + WAFManagementEnabled: wafManagementEnabled, CACertCommonName: certificateManager.CACertCommonName(), Cloud: r.opts.Cloud, CloudResources: mcr, diff --git a/pkg/controller/manager/manager_controller_test.go b/pkg/controller/manager/manager_controller_test.go index b1be3c3828..e0a3c40362 100644 --- a/pkg/controller/manager/manager_controller_test.go +++ b/pkg/controller/manager/manager_controller_test.go @@ -54,6 +54,7 @@ import ( relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" "github.com/tigera/operator/pkg/render/common/rbacmanagement" rsecret "github.com/tigera/operator/pkg/render/common/secret" + "github.com/tigera/operator/pkg/render/common/wafmanagement" "github.com/tigera/operator/pkg/render/logstorage/eck" "github.com/tigera/operator/pkg/render/monitor" tigeratls "github.com/tigera/operator/pkg/tls" @@ -610,7 +611,7 @@ var _ = Describe("Manager controller tests", func() { // 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} + r.client = failingGateReadClient{Client: c, name: rbacmanagement.ConfigMapName, err: readErr} // The shared mockStatus expects a full reconcile, which this returns // early from, so assert the one call. mockStatus.On("SetDegraded", operatorv1.ResourceReadError, @@ -623,6 +624,73 @@ var _ = Describe("Manager controller tests", func() { }) }) + // ui-apis reads WAF_UI_ENABLED at startup, so the controller's half is + // getting the admin's value onto the container. + Context("WAF management UI feature gate", func() { + writeGate := func(value string) { + Expect(c.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: wafmanagement.ConfigMapName, + Namespace: common.CalicoNamespace, + }, + Data: map[string]string{wafmanagement.ConfigMapKey: value}, + })).NotTo(HaveOccurred()) + } + + // wafUIEnv reconciles and reports the value handed to ui-apis. + wafUIEnv := func() string { + mockStatus.On("RemoveCertificateSigningRequests", mock.Anything).Return() + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + d := appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: render.ManagerName, + Namespace: render.ManagerNamespace, + }, + } + Expect(test.GetResource(c, &d)).To(BeNil()) + uiAPIs := test.GetContainer(d.Spec.Template.Spec.Containers, render.UIAPIsName) + Expect(uiAPIs).NotTo(BeNil()) + for _, e := range uiAPIs.Env { + if e.Name == "WAF_UI_ENABLED" { + return e.Value + } + } + Fail("WAF_UI_ENABLED is not set on the ui-apis container") + return "" + } + + It("leaves the UI off when the admin has not created the ConfigMap", func() { + Expect(wafUIEnv()).To(Equal("false")) + }) + + It("turns the UI on once the admin enables the feature", func() { + writeGate("true") + Expect(wafUIEnv()).To(Equal("true")) + }) + + It("leaves the UI off when the admin sets the value to false", func() { + writeGate("false") + Expect(wafUIEnv()).To(Equal("false")) + }) + + // 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, name: wafmanagement.ConfigMapName, err: readErr} + mockStatus.On("SetDegraded", operatorv1.ResourceReadError, + "Error reading the WAF 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 WAF 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()) @@ -1544,15 +1612,16 @@ 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. +// failingGateReadClient fails the read of the named gate ConfigMap and passes everything +// else through, to distinguish an unreadable ConfigMap from an absent one. type failingGateReadClient struct { client.Client - err error + name string + 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 == rbacmanagement.ConfigMapName { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == f.name { return f.err } return f.Client.Get(ctx, key, obj, opts...) diff --git a/pkg/controller/utils/utils.go b/pkg/controller/utils/utils.go index d6c1844245..e7949bf9d9 100644 --- a/pkg/controller/utils/utils.go +++ b/pkg/controller/utils/utils.go @@ -60,6 +60,7 @@ import ( "github.com/tigera/operator/pkg/ctrlruntime" "github.com/tigera/operator/pkg/render" "github.com/tigera/operator/pkg/render/common/rbacmanagement" + "github.com/tigera/operator/pkg/render/common/wafmanagement" "github.com/tigera/operator/pkg/render/logstorage/eck" ) @@ -577,6 +578,23 @@ func RBACManagementEnabled(ctx context.Context, c client.Client, variant operato return rbacmanagement.Enabled(gate), nil } +// WAFManagementEnabled reports whether the WAF management UI should be rendered. +// The feature is Enterprise-only and is not offered on multi-tenant management +// clusters. Otherwise the admin's switch decides; an absent ConfigMap reads as +// disabled. +func WAFManagementEnabled(ctx context.Context, c client.Client, variant operatorv1.ProductVariant, multiTenant bool) (bool, error) { + if !variant.IsEnterprise() || multiTenant { + return false, nil + } + gate, err := GetIfExists[corev1.ConfigMap](ctx, client.ObjectKey{ + Name: wafmanagement.ConfigMapName, Namespace: common.CalicoNamespace, + }, c) + if err != nil { + return false, err + } + return wafmanagement.Enabled(gate), nil +} + // GetNonClusterHost finds the NonClusterHost CR in your cluster. func GetNonClusterHost(ctx context.Context, cli client.Client) (*operatorv1.NonClusterHost, error) { nonclusterhost := &operatorv1.NonClusterHost{} diff --git a/pkg/render/apiserver.go b/pkg/render/apiserver.go index caba0cad5a..ebc6f27095 100644 --- a/pkg/render/apiserver.go +++ b/pkg/render/apiserver.go @@ -48,6 +48,7 @@ import ( "github.com/tigera/operator/pkg/render/common/secret" "github.com/tigera/operator/pkg/render/common/securitycontext" "github.com/tigera/operator/pkg/render/common/securitycontextconstraints" + "github.com/tigera/operator/pkg/render/common/wafmanagement" "github.com/tigera/operator/pkg/tls/certificatemanagement" ) @@ -2222,7 +2223,7 @@ func (c *apiServerComponent) tigeraNetworkAdminClusterRole() *rbacv1.ClusterRole }, }...) - // Write access to the switch, so a network admin can enable the feature without + // Write access to the switches, so a network admin can enable the features 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. @@ -2235,7 +2236,7 @@ func (c *apiServerComponent) tigeraNetworkAdminClusterRole() *rbacv1.ClusterRole rbacv1.PolicyRule{ APIGroups: []string{""}, Resources: []string{"configmaps"}, - ResourceNames: []string{rbacmanagement.ConfigMapName}, + ResourceNames: []string{rbacmanagement.ConfigMapName, wafmanagement.ConfigMapName}, Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, }, ) diff --git a/pkg/render/apiserver_test.go b/pkg/render/apiserver_test.go index d4c85ac2fa..12987ec27d 100644 --- a/pkg/render/apiserver_test.go +++ b/pkg/render/apiserver_test.go @@ -44,6 +44,7 @@ import ( "github.com/tigera/operator/pkg/render/common/podaffinity" "github.com/tigera/operator/pkg/render/common/rbacmanagement" rtest "github.com/tigera/operator/pkg/render/common/test" + "github.com/tigera/operator/pkg/render/common/wafmanagement" "github.com/tigera/operator/pkg/render/testutils" "github.com/tigera/operator/pkg/tls/certificatemanagement" "github.com/tigera/operator/test" @@ -417,7 +418,7 @@ var _ = Describe("API server rendering tests (Calico Enterprise)", func() { { APIGroups: []string{""}, Resources: []string{"configmaps"}, - ResourceNames: []string{rbacmanagement.ConfigMapName}, + ResourceNames: []string{rbacmanagement.ConfigMapName, wafmanagement.ConfigMapName}, Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, }, } @@ -2026,7 +2027,7 @@ var ( ResourceNames: []string{"webhooks-secret"}, Verbs: []string{"patch"}, }, - // Write access to the switch, ungated so it can be used to turn the feature on. + // Write access to the switches, ungated so they can be used to turn the features on. { APIGroups: []string{""}, Resources: []string{"configmaps"}, @@ -2035,7 +2036,7 @@ var ( { APIGroups: []string{""}, Resources: []string{"configmaps"}, - ResourceNames: []string{rbacmanagement.ConfigMapName}, + ResourceNames: []string{rbacmanagement.ConfigMapName, wafmanagement.ConfigMapName}, Verbs: []string{"get", "list", "watch", "update", "patch", "delete"}, }, } diff --git a/pkg/render/common/wafmanagement/gate.go b/pkg/render/common/wafmanagement/gate.go new file mode 100644 index 0000000000..532c92ce78 --- /dev/null +++ b/pkg/render/common/wafmanagement/gate.go @@ -0,0 +1,41 @@ +// 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 wafmanagement reads the admin-owned gate that switches the WAF +// management UI on for a cluster. +package wafmanagement + +import ( + "strconv" + + corev1 "k8s.io/api/core/v1" +) + +const ( + // ConfigMapName is the admin-owned switch for the WAF management UI. Unlike the + // RBAC gate, only the operator reads it: it is projected onto the ui-apis + // container as WAF_UI_ENABLED, so a toggle rolls the manager Deployment. + ConfigMapName = "waf-ui-config" + ConfigMapKey = "waf-ui-enabled" +) + +// Enabled reports whether the WAF management UI is switched on for this cluster. +// A missing ConfigMap, missing key or unparsable value reads as disabled. +func Enabled(cm *corev1.ConfigMap) bool { + if cm == nil { + return false + } + enabled, err := strconv.ParseBool(cm.Data[ConfigMapKey]) + return err == nil && enabled +} diff --git a/pkg/render/common/wafmanagement/gate_test.go b/pkg/render/common/wafmanagement/gate_test.go new file mode 100644 index 0000000000..03ac8fe1f7 --- /dev/null +++ b/pkg/render/common/wafmanagement/gate_test.go @@ -0,0 +1,41 @@ +// 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 wafmanagement_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + + "github.com/tigera/operator/pkg/render/common/wafmanagement" +) + +// 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 WAF management UI switched off. +var _ = DescribeTable("Enabled", + func(cm *corev1.ConfigMap, expected bool) { + Expect(wafmanagement.Enabled(cm)).To(Equal(expected)) + }, + Entry("nil ConfigMap (never created, or deleted)", nil, false), + Entry("missing key", &corev1.ConfigMap{Data: map[string]string{}}, false), + Entry("explicitly disabled", &corev1.ConfigMap{Data: map[string]string{wafmanagement.ConfigMapKey: "false"}}, false), + Entry("enabled", &corev1.ConfigMap{Data: map[string]string{wafmanagement.ConfigMapKey: "true"}}, true), + Entry("enabled, capitalised", &corev1.ConfigMap{Data: map[string]string{wafmanagement.ConfigMapKey: "True"}}, true), + Entry("enabled as 1", &corev1.ConfigMap{Data: map[string]string{wafmanagement.ConfigMapKey: "1"}}, true), + Entry("unparsable value stays off", &corev1.ConfigMap{Data: map[string]string{wafmanagement.ConfigMapKey: "yes please"}}, false), + Entry("empty value stays off", &corev1.ConfigMap{Data: map[string]string{wafmanagement.ConfigMapKey: ""}}, false), +) diff --git a/pkg/render/common/wafmanagement/wafmanagement_suite_test.go b/pkg/render/common/wafmanagement/wafmanagement_suite_test.go new file mode 100644 index 0000000000..e7db5eb89e --- /dev/null +++ b/pkg/render/common/wafmanagement/wafmanagement_suite_test.go @@ -0,0 +1,29 @@ +// 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 wafmanagement_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestWAFManagement(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../../report/ut/wafmanagement_suite.xml" + ginkgo.RunSpecs(t, "pkg/render/common/wafmanagement Suite", suiteConfig, reporterConfig) +} diff --git a/pkg/render/manager.go b/pkg/render/manager.go index b8cfbc1c4d..044e753429 100644 --- a/pkg/render/manager.go +++ b/pkg/render/manager.go @@ -218,6 +218,10 @@ type ManagerConfiguration struct { // The controller has already applied the variant, the admin's gate and tenancy. RBACManagementEnabled bool + // WAFManagementEnabled reports whether to serve the WAF management UI surface. + // The controller has already applied the variant, the admin's gate and tenancy. + WAFManagementEnabled 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 @@ -770,6 +774,7 @@ 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: "WAF_UI_ENABLED", Value: strconv.FormatBool(c.cfg.WAFManagementEnabled)}, } // Determine the Linseed location. Use code default unless in multi-tenant mode, diff --git a/pkg/render/manager_test.go b/pkg/render/manager_test.go index 85d5b6f170..261af60549 100644 --- a/pkg/render/manager_test.go +++ b/pkg/render/manager_test.go @@ -158,6 +158,7 @@ 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: "WAF_UI_ENABLED", Value: "false"}, } Expect(uiAPIs.Env).To(Equal(uiAPIsExpectedEnvVars)) @@ -1846,6 +1847,36 @@ var _ = Describe("Tigera Secure Manager rendering tests", func() { }) }) }) + + Context("WAF management UI", func() { + var installation *operatorv1.InstallationSpec + BeforeEach(func() { + replicas := int32(1) + installation = &operatorv1.InstallationSpec{ + ControlPlaneReplicas: &replicas, + Variant: operatorv1.CalicoEnterprise, + Registry: "testregistry.com/", + } + }) + + // ui-apis reads WAF_UI_ENABLED at startup rather than watching the gate, so the + // admin's value has to reach it through the pod spec. + DescribeTable("projects the gate onto WAF_UI_ENABLED", + func(enabled bool, expected string) { + resources, _ := renderObjects(renderConfig{ + installation: installation, + ns: render.ManagerNamespace, + wafManagementEnabled: enabled, + }) + deployment := rtest.GetResource(resources, render.ManagerDeploymentName, render.ManagerNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) + uiAPIs := rtest.GetContainer(deployment.Spec.Template.Spec.Containers, render.UIAPIsName) + Expect(uiAPIs).NotTo(BeNil()) + Expect(uiAPIs.Env).To(ContainElement(corev1.EnvVar{Name: "WAF_UI_ENABLED", Value: expected})) + }, + Entry("off", false, "false"), + Entry("on", true, "true"), + ) + }) }) type renderConfig struct { @@ -1866,6 +1897,8 @@ type renderConfig struct { ldapHost string // rbacManagementEnabled mirrors the admin's rbac-ui-config value. rbacManagementEnabled bool + // wafManagementEnabled mirrors the admin's waf-ui-config value. + wafManagementEnabled bool cloud bool voltronMetricsEnabled bool cloudResources render.ManagerCloudResources @@ -1940,6 +1973,7 @@ func renderObjects(roc renderConfig) ([]client.Object, []client.Object) { ExternalElastic: roc.externalElastic, CACertCommonName: certificateManager.CACertCommonName(), RBACManagementEnabled: roc.rbacManagementEnabled, + WAFManagementEnabled: roc.wafManagementEnabled, Cloud: roc.cloud, CloudResources: roc.cloudResources, }