diff --git a/pkg/common/common.go b/pkg/common/common.go index efdf171732..544ea35aff 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -36,6 +36,10 @@ const ( EgressAccessControlFeature = "egress-access-control" // PolicyRecommendation feature name PolicyRecommendationFeature = "policy-recommendation" + // GatewayAddonsFeature gates Tigera-built add-ons that layer on top of an + // ingress gateway (currently the WAF v2/v3 admission webhook). The bare + // ingress gateway data path is NOT licensed by this feature. + GatewayAddonsFeature = "ingress-gateway-addons" // MultipleOwnersLabel used to indicate multiple owner references. // If the render code places this label on an object, the object mergeState machinery will merge owner // references with any that already exist on the object rather than replace the owner references. Further diff --git a/pkg/components/enterprise.go b/pkg/components/enterprise.go index 0ff97f0b54..9c00436eb4 100644 --- a/pkg/components/enterprise.go +++ b/pkg/components/enterprise.go @@ -266,6 +266,14 @@ var ( variant: enterpriseVariant, } + ComponentCorazaWASM = Component{ + Version: "master", + Image: "coraza-wasm", + Registry: "", + imagePath: "", + variant: enterpriseVariant, + } + ComponentQueryServer = Component{ Version: "master", Image: "queryserver", @@ -430,6 +438,7 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, + ComponentCorazaWASM, ComponentQueryServer, ComponentPrometheus, ComponentTigeraPrometheusService, diff --git a/pkg/render/applicationlayer/gateway_waf.go b/pkg/render/applicationlayer/gateway_waf.go new file mode 100644 index 0000000000..216bd1057f --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf.go @@ -0,0 +1,239 @@ +// 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 applicationlayer + +import ( + "fmt" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/common" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/securitycontext" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +const ( + // WAFWebhookName is the resource name used for all WAF admission webhook objects. + WAFWebhookName = "tigera-waf-admission-controller" + + // wafWebhookPort is the HTTPS port exposed by the WAF admission webhook. + wafWebhookPort = int32(8443) +) + +// WAFAdmissionWebhookComponents returns the full set of objects required for the WAF +// admission webhook: Deployment, Service, ServiceAccount, ClusterRole, ClusterRoleBinding, +// and ValidatingWebhookConfiguration. +// +// The caller is responsible for invoking this only when the gateway-addons license feature +// is present. +func WAFAdmissionWebhookComponents(install *operatorv1.InstallationSpec, image string, certPair certificatemanagement.KeyPairInterface) []client.Object { + return []client.Object{ + wafWebhookServiceAccount(), + wafWebhookClusterRole(), + wafWebhookClusterRoleBinding(), + wafWebhookDeployment(install, image, certPair), + wafWebhookService(), + wafValidatingWebhookConfiguration(certPair), + } +} + +// ---- WAF admission webhook private constructors ---- + +func wafWebhookServiceAccount() *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookName, + Namespace: common.CalicoNamespace, + Labels: map[string]string{"app": WAFWebhookName}, + }, + } +} + +func wafWebhookClusterRole() *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookName, + Labels: map[string]string{"app": WAFWebhookName}, + }, + Rules: []rbacv1.PolicyRule{ + { + // Required to read GatewayExtension CRs when validating admission requests. + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{"gatewayextensions", "globalwafpolicies"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + } +} + +func wafWebhookClusterRoleBinding() *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookName, + Labels: map[string]string{"app": WAFWebhookName}, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: WAFWebhookName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: WAFWebhookName, + Namespace: common.CalicoNamespace, + }, + }, + } +} + +func wafWebhookDeployment(install *operatorv1.InstallationSpec, image string, certPair certificatemanagement.KeyPairInterface) *appsv1.Deployment { + var replicas int32 = 1 + if install.ControlPlaneReplicas != nil { + replicas = *install.ControlPlaneReplicas + } + + return &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookName, + Namespace: common.CalicoNamespace, + Labels: map[string]string{"app": WAFWebhookName}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": WAFWebhookName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": WAFWebhookName}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: WAFWebhookName, + Containers: []corev1.Container{ + { + Name: WAFWebhookName, + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: securitycontext.NewNonRootContext(), + Args: []string{ + "--tls-cert-file=" + certPair.VolumeMountCertificateFilePath(), + "--tls-private-key-file=" + certPair.VolumeMountKeyFilePath(), + fmt.Sprintf("--port=%d", wafWebhookPort), + }, + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: wafWebhookPort, + Protocol: corev1.ProtocolTCP, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + certPair.VolumeMount(rmeta.OSTypeLinux), + }, + }, + }, + Volumes: []corev1.Volume{ + certPair.Volume(), + }, + }, + }, + }, + } +} + +func wafWebhookService() *corev1.Service { + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookName, + Namespace: common.CalicoNamespace, + Labels: map[string]string{"app": WAFWebhookName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": WAFWebhookName}, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt32(wafWebhookPort), + }, + }, + Type: corev1.ServiceTypeClusterIP, + }, + } +} + +func wafValidatingWebhookConfiguration(certPair certificatemanagement.KeyPairInterface) *admissionregistrationv1.ValidatingWebhookConfiguration { + failPolicy := admissionregistrationv1.Ignore + sideEffects := admissionregistrationv1.SideEffectClassNone + timeoutSeconds := int32(10) + + return &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: "admissionregistration.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "tigera-waf.applicationlayer.projectcalico.org", + Labels: map[string]string{"app": WAFWebhookName}, + }, + Webhooks: []admissionregistrationv1.ValidatingWebhook{ + { + Name: "waf.applicationlayer.projectcalico.org", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + admissionregistrationv1.Update, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + APIVersions: []string{"v3"}, + Resources: []string{"gatewayextensions", "globalwafpolicies"}, + Scope: ptr.To(admissionregistrationv1.AllScopes), + }, + }, + }, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: common.CalicoNamespace, + Name: WAFWebhookName, + Path: ptr.To("/validate"), + }, + CABundle: certPair.GetCertificatePEM(), + }, + AdmissionReviewVersions: []string{"v1"}, + SideEffects: &sideEffects, + TimeoutSeconds: &timeoutSeconds, + FailurePolicy: &failPolicy, + }, + }, + } +} diff --git a/pkg/render/applicationlayer/gateway_waf_test.go b/pkg/render/applicationlayer/gateway_waf_test.go new file mode 100644 index 0000000000..aa4049a268 --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf_test.go @@ -0,0 +1,85 @@ +// 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 applicationlayer_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/render/applicationlayer" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +// fakeCertPair is a minimal stub that satisfies certificatemanagement.KeyPairInterface +// for unit tests that only need the render functions to produce objects without real TLS +// material. +type fakeCertPair struct{} + +var _ certificatemanagement.KeyPairInterface = (*fakeCertPair)(nil) + +func (f *fakeCertPair) UseCertificateManagement() bool { return false } +func (f *fakeCertPair) BYO() bool { return true } +func (f *fakeCertPair) InitContainer(_ string, _ *corev1.SecurityContext) corev1.Container { + return corev1.Container{} +} +func (f *fakeCertPair) VolumeMount(_ rmeta.OSType) corev1.VolumeMount { + return corev1.VolumeMount{Name: "tls-certs", MountPath: "/tls"} +} +func (f *fakeCertPair) VolumeMountKeyFilePath() string { return "/tls/tls.key" } +func (f *fakeCertPair) VolumeMountCertificateFilePath() string { return "/tls/tls.crt" } +func (f *fakeCertPair) Volume() corev1.Volume { + return corev1.Volume{ + Name: "tls-certs", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: "fake-tls"}, + }, + } +} +func (f *fakeCertPair) Secret(_ string) *corev1.Secret { + return &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "fake-tls"}} +} +func (f *fakeCertPair) HashAnnotationKey() string { return "hash.operator.tigera.io/fake-tls" } +func (f *fakeCertPair) HashAnnotationValue() string { return "fake-hash" } +func (f *fakeCertPair) Warnings() string { return "" } +func (f *fakeCertPair) GetCertificatePEM() []byte { return []byte("fake-ca") } +func (f *fakeCertPair) GetIssuer() certificatemanagement.CertificateInterface { + return nil +} +func (f *fakeCertPair) GetName() string { return "fake-tls" } +func (f *fakeCertPair) GetNamespace() string { return "calico-system" } + +var minimalInstallation = operatorv1.InstallationSpec{ + KubernetesProvider: operatorv1.ProviderNone, +} + +func TestWAFAdmissionWebhookComponents_HasExpectedKinds(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(&minimalInstallation, "tigera/waf-admission-controller:v0.1.0", &fakeCertPair{}) + got := map[string]int{} + for _, o := range objs { + got[o.GetObjectKind().GroupVersionKind().Kind]++ + } + require.Equal(t, 1, got["Deployment"], "expected 1 Deployment") + require.Equal(t, 1, got["Service"], "expected 1 Service") + require.Equal(t, 1, got["ServiceAccount"], "expected 1 ServiceAccount") + require.Equal(t, 1, got["ClusterRole"], "expected 1 ClusterRole") + require.Equal(t, 1, got["ClusterRoleBinding"], "expected 1 ClusterRoleBinding") + require.Equal(t, 1, got["ValidatingWebhookConfiguration"], "expected 1 ValidatingWebhookConfiguration") + require.Len(t, objs, 6, "expected exactly 6 objects") +} diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index af72b72c1e..6d5524b1df 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -154,7 +154,7 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController Verbs: []string{"create", "update", "delete", "watch", "list", "get"}, }, ) - enabledControllers = append(enabledControllers, "service", "federatedservices", "usage") + enabledControllers = append(enabledControllers, "service", "federatedservices", "usage", "applicationlayer") } return &kubeControllersComponent{ @@ -234,6 +234,12 @@ type kubeControllersComponent struct { kubeControllerCalicoSystemPolicy *v3.NetworkPolicy enabledControllers []string + + // wasmImage is the fully-resolved OCI reference for the Coraza WAF wasm + // binary (Enterprise only). Surfaced to the kube-controllers binary via + // the WASM_IMAGE env var; consumed by the applicationlayer reconcilers + // in tigera/calico-private to program WAF policy attachments. + wasmImage string } func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error { @@ -242,7 +248,16 @@ func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error prefix := c.cfg.Installation.ImagePrefix var err error c.image, err = components.GetReference(components.CombinedCalicoImage(c.cfg.Installation), reg, path, prefix, is) - return err + if err != nil { + return err + } + if c.cfg.Installation.Variant.IsEnterprise() { + c.wasmImage, err = components.GetReference(components.ComponentCorazaWASM, reg, path, prefix, is) + if err != nil { + return err + } + } + return nil } func (c *kubeControllersComponent) SupportedOSType() rmeta.OSType { @@ -469,6 +484,75 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) Resources: []string{"packetcaptures"}, Verbs: []string{"get", "list", "update"}, }, + // Application-layer (gateway-addons) reconcilers reconcile WAF resources + // against Gateway API targetRefs and emit events on the policy objects. + { + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + { + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/status", "globalwafpolicies/status", + "wafplugins/status", "globalwafplugins/status", + "wafvalidationpolicies/status", "globalwafvalidationpolicies/status", + }, + Verbs: []string{"get", "update", "patch"}, + }, + { + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + }, + { + // Validate Gateway API targetRefs and surface attachment status. + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "tcproutes", "tlsroutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, + }, + { + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways/status", "httproutes/status", "tcproutes/status", "tlsroutes/status", "grpcroutes/status"}, + Verbs: []string{"get", "update", "patch"}, + }, + // controller-runtime Reconcilers (e.g. the applicationlayer manager) record + // events on watched objects via Recorder.Eventf; both core and events.k8s.io + // API groups are emitted depending on the kubernetes version. + { + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + { + APIGroups: []string{"events.k8s.io"}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + // Application-layer reconciler replicates the WAF wasm pull Secret from + // the controller namespace (calico-system) into each WAFPolicy's + // namespace so the rendered EnvoyExtensionPolicy can reference it. Also + // replicates CA-cert ConfigMaps when WASM_CA_CERT is set. + { + APIGroups: []string{""}, + Resources: []string{"secrets", "configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + // Application-layer reconciler emits one EnvoyExtensionPolicy per WAF + // targetRef to bind the Coraza wasm filter at the gateway / route. + { + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, } if cfg.ManagementClusterConnection != nil { @@ -566,6 +650,32 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { if c.cfg.Installation.CalicoNetwork != nil && c.cfg.Installation.CalicoNetwork.MultiInterfaceMode != nil { env = append(env, corev1.EnvVar{Name: "MULTI_INTERFACE_MODE", Value: c.cfg.Installation.CalicoNetwork.MultiInterfaceMode.Value()}) } + + // Application-layer (gateway-addons) reconcilers consume the Coraza WAF + // wasm OCI reference from this env var to program WAF policy attachments. + // Empty when ResolveImages was not called for the Calico variant; the + // reconciler stamps Programmed=False/WASMUnavailable in that case. + if c.wasmImage != "" { + env = append(env, corev1.EnvVar{Name: "WASM_IMAGE", Value: c.wasmImage}) + } + + // WASM_PULL_SECRET names the imagePullSecret the reconciler replicates + // from the kube-controllers namespace into a WAFPolicy's namespace so + // the rendered EnvoyExtensionPolicy can pull the wasm OCI artifact from + // a private Tigera registry. Source the name from the first + // Installation.ImagePullSecrets entry so multi-tenant / BYO-registry + // installs reuse whatever pull secret operator already attaches here. + if len(c.cfg.Installation.ImagePullSecrets) > 0 { + env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.Installation.ImagePullSecrets[0].Name}) + } + + // WASM_CA_CERT names the trusted CA bundle ConfigMap (already mounted + // on this Deployment via TrustedBundle) that the reconciler replicates + // alongside WASM_PULL_SECRET so the EnvoyExtensionPolicy wasm fetcher + // trusts the registry's TLS chain. + if c.cfg.TrustedBundle != nil { + env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName}) + } } if c.cfg.MetricsServerTLS != nil { diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index 0f673a2280..70bd290f5d 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -244,6 +244,11 @@ var _ = Describe("kube-controllers rendering tests", func() { } instance.Variant = operatorv1.CalicoEnterprise + // Pull secret on the Installation propagates through the Deployment's + // imagePullSecrets and is also surfaced via WASM_PULL_SECRET so the + // applicationlayer reconciler can reference it from rendered + // EnvoyExtensionPolicies in WAFPolicy namespaces. + instance.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "tigera-pull-secret"}} cfg.MetricsPort = 9094 component := kubecontrollers.NewCalicoKubeControllers(&cfg) @@ -262,16 +267,95 @@ var _ = Describe("kube-controllers rendering tests", func() { dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) + 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", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", + })) + // Application-layer reconcilers consume these env vars to program WAF + // EnvoyExtensionPolicy attachments. + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_IMAGE", Value: "test-reg/tigera/coraza-wasm:" + components.ComponentCorazaWASM.Version, + })) + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_PULL_SECRET", Value: "tigera-pull-secret", + })) + // TrustedBundle is set on the configuration above, so WASM_CA_CERT + // names the standard tigera trusted-bundle ConfigMap. + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName, })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) 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(27), "cluster role should have 27 rules") + Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") + + // Application-layer reconciler RBAC: WAF CRDs (resources, /status, /finalizers). + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/status", "globalwafpolicies/status", + "wafplugins/status", "globalwafplugins/status", + "wafvalidationpolicies/status", "globalwafvalidationpolicies/status", + }, + Verbs: []string{"get", "update", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + })) + // Gateway API targetRef validation + status patching. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "tcproutes", "tlsroutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways/status", "httproutes/status", "tcproutes/status", "tlsroutes/status", "grpcroutes/status"}, + Verbs: []string{"get", "update", "patch"}, + })) + // Recorder.Eventf emits to both core/events and events.k8s.io/events. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"events.k8s.io"}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + })) + // Cluster-wide secrets+configmaps CRUD: reconciler replicates pull + // secrets and CA bundles from the controller namespace into target + // WAFPolicy namespaces. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets", "configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) + // EnvoyExtensionPolicy CRUD: reconciler renders one EEP per WAF targetRef. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) ms := rtest.GetResource(resources, kubecontrollers.KubeControllerMetrics, common.CalicoNamespace, "", "v1", "Service").(*corev1.Service) Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless") @@ -358,7 +442,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Volumes[0].ConfigMap.Name).To(Equal("tigera-ca-bundle")) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(25), "cluster role should have 25 rules") + Expect(clusterRole.Rules).To(HaveLen(34), "cluster role should have 34 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""}, @@ -412,7 +496,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", + Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) @@ -569,7 +653,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(25), "cluster role should have 25 rules") + Expect(clusterRole.Rules).To(HaveLen(34), "cluster role should have 34 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""},