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, }