diff --git a/api/v1/gatewayapi_types.go b/api/v1/gatewayapi_types.go index 7e56ae8ede..857ccf3817 100644 --- a/api/v1/gatewayapi_types.go +++ b/api/v1/gatewayapi_types.go @@ -79,6 +79,51 @@ type GatewayAPISpec struct { // does not yet have any version of those CRDs. // +optional CRDManagement *CRDManagement `json:"crdManagement,omitempty"` + + // Extensions enables and configures Tigera-built add-ons that sit on top of the + // Gateway API data plane. Each add-on is opt-in: an unset Extensions, an unset + // add-on field, and an empty add-on object all leave the add-on disabled. + // +optional + Extensions *GatewayAPIExtensions `json:"extensions,omitempty"` +} + +// GatewayAPIExtensions enables and configures Tigera-built Gateway API add-ons. +type GatewayAPIExtensions struct { + // WAF enables and configures the Tigera Web Application Firewall (Coraza WASM + // + applicationlayer reconcilers). Default-off semantics: when WAF is nil, + // when WAF.State is nil, and when WAF.State is "Disabled", the operator does + // not render the WAF env vars or RBAC on calico-kube-controllers. Set + // WAF.State = "Enabled" to turn the feature on. See design + // `tigera/designs#25` (PMREQ-384) for the full surface. + // +optional + WAF *WAFExtensionSpec `json:"waf,omitempty"` +} + +// WAFExtensionSpec configures the WAF Gateway API add-on. +type WAFExtensionSpec struct { + // State turns the WAF Gateway API add-on on or off. Default (nil or + // "Disabled") means the operator does not render the WAF surface on + // calico-kube-controllers. Set to "Enabled" to opt in. + // +optional + State *WAFExtensionState `json:"state,omitempty"` +} + +// WAFExtensionState is the on/off enum for the WAF Gateway API add-on. +// +kubebuilder:validation:Enum=Enabled;Disabled +type WAFExtensionState string + +const ( + WAFExtensionStateEnabled WAFExtensionState = "Enabled" + WAFExtensionStateDisabled WAFExtensionState = "Disabled" +) + +// IsWAFGatewayExtensionEnabled returns true iff spec.extensions.waf.state == Enabled. +// Unset Extensions, unset WAF, unset State, and explicit Disabled all return false. +func (s *GatewayAPISpec) IsWAFGatewayExtensionEnabled() bool { + if s == nil || s.Extensions == nil || s.Extensions.WAF == nil || s.Extensions.WAF.State == nil { + return false + } + return *s.Extensions.WAF.State == WAFExtensionStateEnabled } type GatewayClassSpec struct { diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 7a26d77a2f..94e138e067 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -1,21 +1,5 @@ //go:build !ignore_autogenerated -// Copyright (c) 2024 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. -*/ - // Code generated by controller-gen. DO NOT EDIT. package v1 @@ -4360,6 +4344,26 @@ func (in *GatewayAPI) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayAPIExtensions) DeepCopyInto(out *GatewayAPIExtensions) { + *out = *in + if in.WAF != nil { + in, out := &in.WAF, &out.WAF + *out = new(WAFExtensionSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayAPIExtensions. +func (in *GatewayAPIExtensions) DeepCopy() *GatewayAPIExtensions { + if in == nil { + return nil + } + out := new(GatewayAPIExtensions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayAPIList) DeepCopyInto(out *GatewayAPIList) { *out = *in @@ -4422,6 +4426,11 @@ func (in *GatewayAPISpec) DeepCopyInto(out *GatewayAPISpec) { *out = new(CRDManagement) **out = **in } + if in.Extensions != nil { + in, out := &in.Extensions, &out.Extensions + *out = new(GatewayAPIExtensions) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayAPISpec. @@ -9806,6 +9815,26 @@ func (in *UserSearch) DeepCopy() *UserSearch { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WAFExtensionSpec) DeepCopyInto(out *WAFExtensionSpec) { + *out = *in + if in.State != nil { + in, out := &in.State, &out.State + *out = new(WAFExtensionState) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WAFExtensionSpec. +func (in *WAFExtensionSpec) DeepCopy() *WAFExtensionSpec { + if in == nil { + return nil + } + out := new(WAFExtensionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Whisker) DeepCopyInto(out *Whisker) { *out = *in 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 d953ed6a4a..75ff11d5f7 100644 --- a/pkg/components/enterprise.go +++ b/pkg/components/enterprise.go @@ -162,6 +162,30 @@ var ( variant: enterpriseVariant, } + ComponentCorazaWASM = Component{ + Version: "master", + Image: "coraza-wasm", + Registry: "", + imagePath: "", + variant: enterpriseVariant, + } + + ComponentQueryServer = Component{ + Version: "master", + Image: "queryserver", + Registry: "", + imagePath: "", + variant: enterpriseVariant, + } + + ComponentL7AdmissionController = Component{ + Version: "master", + Image: "l7-admission-controller", + Registry: "", + imagePath: "", + variant: enterpriseVariant, + } + ComponentCoreOSPrometheus = Component{ Version: "v3.9.1", variant: enterpriseVariant, @@ -283,6 +307,8 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, + ComponentCorazaWASM, + ComponentQueryServer, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode, diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index ebce8b40f4..c8ea9d3002 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -213,6 +213,13 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { } // Watch for changes to KubeControllersConfiguration. + // Watch GatewayAPI: spec.extensions.waf.state gates the WAF v3 surface on + // calico-kube-controllers. See design tigera/designs#25 (PMREQ-384) §Gating. + if err := c.WatchObject(&operatorv1.GatewayAPI{}, &handler.EnqueueRequestForObject{}); err != nil { + log.V(5).Info("Failed to create GatewayAPI watch", "err", err) + return fmt.Errorf("core-controller failed to watch operator GatewayAPI resource: %w", err) + } + err = c.WatchObject(&v3.KubeControllersConfiguration{}, &handler.EnqueueRequestForObject{}) if err != nil { return fmt.Errorf("tigera-installation-controller failed to watch KubeControllersConfiguration resource: %w", err) @@ -1595,6 +1602,20 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile } components = append(components, render.CSI(&csiCfg)) + // Read the GatewayAPI CR (if present) to decide whether to render the WAF + // v3 (Gateway API add-on) surface — env vars, RBAC, applicationlayer + // reconciler — on calico-kube-controllers. Default-off: if no GatewayAPI + // CR exists or spec.extensions.waf.state != Enabled, the WAF surface is + // not rendered. See design tigera/designs#25 (PMREQ-384) §Gating. + wafGatewayExtensionEnabled := false + gatewayAPI := &operatorv1.GatewayAPI{} + if err := r.client.Get(ctx, utils.DefaultInstanceKey, gatewayAPI); err == nil { + wafGatewayExtensionEnabled = gatewayAPI.Spec.IsWAFGatewayExtensionEnabled() + } else if !apierrors.IsNotFound(err) { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading GatewayAPI", err, reqLogger) + return reconcile.Result{}, err + } + // Build a configuration for rendering calico/kube-controllers. kubeControllersCfg := kubecontrollers.KubeControllersConfiguration{ K8sServiceEp: k8sapi.Endpoint, @@ -1609,6 +1630,7 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile TrustedBundle: typhaNodeTLS.TrustedBundle, Namespace: common.CalicoNamespace, BindingNamespaces: []string{common.CalicoNamespace}, + WAFGatewayExtensionEnabled: wafGatewayExtensionEnabled, } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) diff --git a/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml b/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml index ef44739d46..daa87037e0 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml @@ -83,6 +83,32 @@ spec: - name - namespace type: object + extensions: + description: |- + Extensions enables and configures Tigera-built add-ons that sit on top of the + Gateway API data plane. Each add-on is opt-in: an unset Extensions, an unset + add-on field, and an empty add-on object all leave the add-on disabled. + properties: + waf: + description: |- + WAF enables and configures the Tigera Web Application Firewall (Coraza WASM + + applicationlayer reconcilers). Default-off semantics: when WAF is nil, + when WAF.State is nil, and when WAF.State is "Disabled", the operator does + not render the WAF env vars or RBAC on calico-kube-controllers. Set + WAF.State = "Enabled" to turn the feature on. See design + `tigera/designs#25` (PMREQ-384) for the full surface. + properties: + state: + description: |- + State turns the WAF Gateway API add-on on or off. Default (nil or + "Disabled") means the operator does not render the WAF surface on + calico-kube-controllers. Set to "Enabled" to opt in. + enum: + - Enabled + - Disabled + type: string + type: object + type: object gatewayCertgenJob: description: Allows customization of the gateway certgen job. properties: 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 e6596b70b9..95f6db2b26 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -108,6 +108,15 @@ type KubeControllersConfiguration struct { // Tenant object provides tenant configuration for both single and multi-tenant modes. // If this is nil, then we should run in zero-tenant mode. Tenant *operatorv1.Tenant + + // WAFGatewayExtensionEnabled gates the WAF v3 (Gateway API add-on) surface + // on calico-kube-controllers: the applicationlayer controller enablement, + // the WAF / Gateway-API / EnvoyExtensionPolicy / event / secret-replication + // RBAC, the WASM_IMAGE / WASM_PULL_SECRET / WASM_CA_CERT env vars, and the + // coraza-wasm image resolution. Sourced from + // `GatewayAPI.spec.extensions.waf.state == Enabled` (default off). + // See design `tigera/designs#25` (PMREQ-384). + WAFGatewayExtensionEnabled bool } func NewCalicoKubeControllersPolicy(cfg *KubeControllersConfiguration, defaultDeny *v3.NetworkPolicy) render.Component { @@ -155,6 +164,9 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController }, ) enabledControllers = append(enabledControllers, "service", "federatedservices", "usage") + if cfg.WAFGatewayExtensionEnabled { + enabledControllers = append(enabledControllers, "applicationlayer") + } } return &kubeControllersComponent{ @@ -234,6 +246,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 +260,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.cfg.WAFGatewayExtensionEnabled { + c.wasmImage, err = components.GetReference(components.ComponentCorazaWASM, reg, path, prefix, is) + if err != nil { + return err + } + } + return nil } func (c *kubeControllersComponent) SupportedOSType() rmeta.OSType { @@ -471,6 +498,82 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) }, } + if cfg.WAFGatewayExtensionEnabled { + // WAF v3 (Gateway API add-on) RBAC. Gated by + // GatewayAPI.spec.extensions.waf.state == Enabled. + rules = append(rules, + // Application-layer (gateway-addons) reconcilers reconcile WAF resources + // against Gateway API targetRefs and emit events on the policy objects. + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + 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"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + }, + rbacv1.PolicyRule{ + // 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"}, + }, + 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"}, + }, + // 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. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + rbacv1.PolicyRule{ + 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. + rbacv1.PolicyRule{ + 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. + rbacv1.PolicyRule{ + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + ) + } + if cfg.ManagementClusterConnection != nil { rules = append(rules, rbacv1.PolicyRule{ @@ -566,6 +669,39 @@ 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 / WAF v3) env vars, gated by + // GatewayAPI.spec.extensions.waf.state == Enabled. When the gate is + // off (default), none of the WASM_* env vars are rendered and the + // kube-controllers binary skips the WAF reconcilers entirely (see the + // applicationlayer entry in enabledControllers). + if c.cfg.WAFGatewayExtensionEnabled { + // 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..20026101df 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -244,7 +244,14 @@ 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 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -262,16 +269,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") @@ -326,6 +412,8 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.LogStorageExists = true cfg.KubeControllersGatewaySecret = &testutils.KubeControllersUserSecret cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewElasticsearchKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -358,7 +446,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{""}, @@ -393,6 +481,8 @@ var _ = Describe("kube-controllers rendering tests", func() { instance.Variant = operatorv1.CalicoEnterprise cfg.ManagementCluster = &operatorv1.ManagementCluster{} cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -412,7 +502,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)) @@ -536,6 +626,8 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.ManagementCluster = &operatorv1.ManagementCluster{} cfg.KubeControllersGatewaySecret = &testutils.KubeControllersUserSecret cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewElasticsearchKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -569,7 +661,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{""},