diff --git a/hack/e2e-afd-poc.sh b/hack/e2e-afd-poc.sh new file mode 100755 index 00000000..f45317a9 --- /dev/null +++ b/hack/e2e-afd-poc.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# hack/e2e-afd-poc.sh +# +# Tier-0 smoke test for the hub-afd-controller-manager (PATCH 06 reconciler). +# See docs/first-party/004-poc-runbook.md for the full test plan; this script +# automates only Tier 0 (30-minute FrontDoorProfile round-trip). +# +# What this creates in Azure (all billable): +# * A resource group +# * A 2-node AKS cluster with OIDC issuer + Workload Identity +# * A user-assigned managed identity with Contributor on the RG +# * A federated credential for the controller's ServiceAccount +# * A single FrontDoorProfile CR that the controller reconciles into an +# AFD Premium profile + WAF SecurityPolicy +# +# Idempotent: safe to re-run. Cleanup: `az group delete -n $RG --yes --no-wait`. +# +# Required env (or edit defaults below): +# AZURE_SUBSCRIPTION_ID subscription to deploy into +# AFD_POC_RG resource group name (default: afd-poc-rg) +# AFD_POC_LOC region (default: eastus2) +# AFD_POC_AKS AKS cluster name (default: afd-poc-aks) +# AFD_POC_UAMI managed identity name (default: hub-afd-uami) +# AFD_POC_IMAGE_REPO controller image repository (default: ghcr.io/azure/fleet-networking/hub-afd-controller-manager) +# AFD_POC_IMAGE_TAG controller image tag (default: v0.1.0) +# +# Preconditions checked at runtime: az/kubectl/helm installed, `az account show` +# succeeds, current directory is the fleet-networking repo root. + +set -euo pipefail + +RG="${AFD_POC_RG:-afd-poc-rg}" +LOC="${AFD_POC_LOC:-eastus2}" +AKS="${AFD_POC_AKS:-afd-poc-aks}" +UAMI="${AFD_POC_UAMI:-hub-afd-uami}" +SA_NAMESPACE="fleet-system" +SA_NAME="hub-afd-controller-manager" +IMAGE_REPO="${AFD_POC_IMAGE_REPO:-ghcr.io/azure/fleet-networking/hub-afd-controller-manager}" +IMAGE_TAG="${AFD_POC_IMAGE_TAG:-v0.1.0}" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +require() { command -v "$1" >/dev/null 2>&1 || { echo "missing: $1" >&2; exit 1; }; } + +require az +require kubectl +require helm + +[[ -d charts/hub-afd-controller-manager ]] \ + || { echo "run from fleet-networking repo root" >&2; exit 1; } + +SUB="${AZURE_SUBSCRIPTION_ID:-$(az account show --query id -o tsv)}" +TENANT="$(az account show --query tenantId -o tsv)" +az account set --subscription "$SUB" + +log "Ensure resource group $RG in $LOC" +az group create -n "$RG" -l "$LOC" -o none + +log "Ensure AKS $AKS (this can take ~5 min on first run)" +if ! az aks show -n "$AKS" -g "$RG" -o none 2>/dev/null; then + az aks create -n "$AKS" -g "$RG" \ + --enable-oidc-issuer --enable-workload-identity \ + --node-count 2 --generate-ssh-keys --enable-managed-identity -o none +fi +az aks get-credentials -n "$AKS" -g "$RG" --overwrite-existing + +OIDC_ISSUER="$(az aks show -n "$AKS" -g "$RG" --query oidcIssuerProfile.issuerUrl -o tsv)" + +log "Ensure managed identity $UAMI" +if ! az identity show -n "$UAMI" -g "$RG" -o none 2>/dev/null; then + az identity create -n "$UAMI" -g "$RG" -o none +fi +UAMI_CLIENT_ID="$(az identity show -n "$UAMI" -g "$RG" --query clientId -o tsv)" +UAMI_PRINCIPAL_ID="$(az identity show -n "$UAMI" -g "$RG" --query principalId -o tsv)" + +log "Grant $UAMI Contributor on $RG (needed to create AFD profiles + WAF policies)" +SCOPE="/subscriptions/$SUB/resourceGroups/$RG" +az role assignment create --assignee-object-id "$UAMI_PRINCIPAL_ID" \ + --assignee-principal-type ServicePrincipal --role Contributor \ + --scope "$SCOPE" -o none 2>/dev/null || true + +log "Federate $UAMI to $SA_NAMESPACE/$SA_NAME" +if ! az identity federated-credential show \ + --identity-name "$UAMI" -g "$RG" --name afd-federation -o none 2>/dev/null; then + az identity federated-credential create \ + --identity-name "$UAMI" -g "$RG" --name afd-federation \ + --issuer "$OIDC_ISSUER" \ + --subject "system:serviceaccount:${SA_NAMESPACE}:${SA_NAME}" \ + --audiences api://AzureADTokenExchange -o none +fi + +log "Apply AFD CRDs" +kubectl apply -f config/crd/bases/networking.fleet.azure.com_frontdoorprofiles.yaml +kubectl apply -f config/crd/bases/networking.fleet.azure.com_frontdoorbackends.yaml +kubectl apply -f config/crd/bases/networking.fleet.azure.com_frontdoorcustomdomains.yaml + +log "Install hub-afd-controller-manager chart" +kubectl create namespace "$SA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - +helm upgrade --install hub-afd charts/hub-afd-controller-manager \ + --namespace "$SA_NAMESPACE" \ + --set azure.tenantId="$TENANT" \ + --set azure.clientId="$UAMI_CLIENT_ID" \ + --set azure.subscriptionID="$SUB" \ + --set image.repository="$IMAGE_REPO" \ + --set image.tag="$IMAGE_TAG" + +log "Wait for controller pods to be Ready" +kubectl rollout status -n "$SA_NAMESPACE" deploy/hub-afd-controller-manager --timeout=180s + +log "Apply a minimal FrontDoorProfile CR" +PROFILE_NAME="afd-poc" +cat </dev/null || true)" + if [[ "$READY" == "True" ]]; then + log "FrontDoorProfile is Ready" + break + fi + sleep 10 +done + +kubectl get frontdoorprofile "$PROFILE_NAME" -n "$SA_NAMESPACE" -o yaml + +log "Verify in Azure: AFD profile + attached WAF SecurityPolicy" +az afd profile show -g "$RG" --profile-name "$PROFILE_NAME" -o table || true +az afd security-policy list -g "$RG" --profile-name "$PROFILE_NAME" -o table || true + +cat < Programmed=True +// with hostname populated) so the scaffolding is proven and later work items +// (WAFPolicy, ComplianceMode, error injection) can grow specs incrementally. + +const ( + // eventuallyTimeout is generous because envtest's initial informer sync + // plus the fake LRO round-trip can take a couple of hundred ms on a cold + // cache; a shorter budget is flaky under CI load. + eventuallyTimeout = 30 * time.Second + eventuallyInterval = 250 * time.Millisecond +) + +var _ = Describe("FrontDoorProfile Controller Integration", func() { + Context("Happy path — create a FrontDoorProfile", func() { + const profileName = "test-happy-path" + + AfterEach(func() { + // Delete the CR to exercise the finalizer path so state does not + // leak between specs. Not-found on Delete is fine because a + // failing test may already have deleted it. + profile := &fleetnetv1alpha1.FrontDoorProfile{} + key := types.NamespacedName{Namespace: testNamespace, Name: profileName} + if err := k8sClient.Get(ctx, key, profile); err == nil { + Expect(k8sClient.Delete(ctx, profile)).To(Succeed()) + } + // Wait for the finalizer to run and the CR to disappear so the + // next spec starts from a clean state. + Eventually(func() bool { + err := k8sClient.Get(ctx, key, &fleetnetv1alpha1.FrontDoorProfile{}) + return err != nil + }, eventuallyTimeout, eventuallyInterval).Should(BeTrue(), "profile should be deleted after finalizer runs") + }) + + It("should program the AFD profile and default endpoint", func() { + By("creating a FrontDoorProfile CR") + profile := &fleetnetv1alpha1.FrontDoorProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: profileName, + Namespace: testNamespace, + }, + Spec: fleetnetv1alpha1.FrontDoorProfileSpec{ + ResourceGroup: fakeprovider.DefaultResourceGroupName, + Sku: fleetnetv1alpha1.FrontDoorProfileSkuPremium, + }, + } + Expect(k8sClient.Create(ctx, profile)).To(Succeed()) + + By("waiting for Programmed=True condition") + Eventually(func(g Gomega) { + got := &fleetnetv1alpha1.FrontDoorProfile{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: profileName}, got)).To(Succeed()) + + cond := meta.FindStatusCondition(got.Status.Conditions, + string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed)) + g.Expect(cond).NotTo(BeNil(), "Programmed condition should be present") + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(string(fleetnetv1alpha1.FrontDoorProfileReasonProgrammed))) + g.Expect(cond.ObservedGeneration).To(Equal(got.Generation)) + + // EndpointHostname is derived from the fake's synthetic + // AFDEndpointProperties.HostName (fakeprovider.EndpointHostnameFormat). + g.Expect(got.Status.EndpointHostname).NotTo(BeNil()) + g.Expect(*got.Status.EndpointHostname).To(Equal(fmt.Sprintf(fakeprovider.EndpointHostnameFormat, AzureEndpointName(got)))) + + // ResourceID uses the RG-and-below suffix format + // (see azureResourceIDForProfile). Assert the shape rather + // than the exact UID (UID is server-assigned). + g.Expect(got.Status.ResourceID).To(Equal(fmt.Sprintf( + "/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s", + fakeprovider.DefaultResourceGroupName, AzureProfileName(got)))) + + // Finalizer must be present so the controller can clean up + // Azure state on delete. + g.Expect(got.Finalizers).To(ContainElement(objectmeta.FrontDoorProfileFinalizer)) + }, eventuallyTimeout, eventuallyInterval).Should(Succeed()) + }) + }) + + // deleteProfile is a shared teardown helper for the WAF specs below. + // Mirrors the happy-path Context's AfterEach: delete the CR (if present) + // and wait for the finalizer to run so the next spec starts clean. + // Factored here rather than duplicated so a change to teardown semantics + // (e.g. tightening the timeout) lands in one place. + deleteProfile := func(profileName string) { + key := types.NamespacedName{Namespace: testNamespace, Name: profileName} + profile := &fleetnetv1alpha1.FrontDoorProfile{} + if err := k8sClient.Get(ctx, key, profile); err == nil { + Expect(k8sClient.Delete(ctx, profile)).To(Succeed()) + } + Eventually(func() bool { + return k8sClient.Get(ctx, key, &fleetnetv1alpha1.FrontDoorProfile{}) != nil + }, eventuallyTimeout, eventuallyInterval).Should(BeTrue(), + "profile %s should be deleted after finalizer runs", profileName) + } + + Context("WAF happy path — resolve + attach a Prevention-mode policy", func() { + const ( + profileName = "test-waf-happy" + wafRG = fakeprovider.DefaultResourceGroupName + wafName = "waf-happy" + ) + + BeforeEach(func() { + // Seed the referenced WAF policy in Prevention mode BEFORE + // creating the profile CR so the very first reconcile pass + // resolves it. If we seeded after, the first reconcile would + // briefly observe WAFPolicyNotFound and flake the assertion. + wafPolicyFake.SetPolicy(wafRG, wafName, armfrontdoor.PolicyModePrevention) + }) + + AfterEach(func() { + deleteProfile(profileName) + wafPolicyFake.DeletePolicy(wafRG, wafName) + }) + + It("should program the profile AND write a SecurityPolicy binding the WAF policy to the default endpoint", func() { + By("creating a FrontDoorProfile CR that references the seeded WAF policy") + wafID := fakeprovider.FormatWAFPolicyResourceID(wafRG, wafName) + profile := &fleetnetv1alpha1.FrontDoorProfile{ + ObjectMeta: metav1.ObjectMeta{Name: profileName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorProfileSpec{ + ResourceGroup: fakeprovider.DefaultResourceGroupName, + Sku: fleetnetv1alpha1.FrontDoorProfileSkuPremium, + WAFPolicy: &fleetnetv1alpha1.FrontDoorWAFPolicyRef{ResourceID: wafID}, + }, + } + Expect(k8sClient.Create(ctx, profile)).To(Succeed()) + + By("waiting for Programmed=True and a SecurityPolicy stored in the fake") + Eventually(func(g Gomega) { + got := &fleetnetv1alpha1.FrontDoorProfile{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: profileName}, got)).To(Succeed()) + + cond := meta.FindStatusCondition(got.Status.Conditions, + string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed)) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(string(fleetnetv1alpha1.FrontDoorProfileReasonProgrammed))) + + // SecurityPolicy is named deterministically off the CR UID + // so the reconciler and the assertion agree without + // coordinating out-of-band. + secName := fmt.Sprintf("fleet-waf-%s", got.UID) + _, ok := securityPolicyFake.GetStored(AzureProfileName(got), secName) + g.Expect(ok).To(BeTrue(), "SecurityPolicy %s should be stored in the fake", secName) + }, eventuallyTimeout, eventuallyInterval).Should(Succeed()) + + By("asserting the stored SecurityPolicy binds the correct WAF policy and endpoint") + got := &fleetnetv1alpha1.FrontDoorProfile{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: profileName}, got)).To(Succeed()) + secName := fmt.Sprintf("fleet-waf-%s", got.UID) + stored, ok := securityPolicyFake.GetStored(AzureProfileName(got), secName) + Expect(ok).To(BeTrue()) + // Parameters is a classification interface (SDK-level union to + // leave room for future policy types). Type-assert to the WAF + // variant to inspect the fields we actually populate. + Expect(stored.Properties).NotTo(BeNil()) + wafParams, isWAF := stored.Properties.Parameters.(*armcdn.SecurityPolicyWebApplicationFirewallParameters) + Expect(isWAF).To(BeTrue(), "stored SecurityPolicy should carry WebApplicationFirewall params") + Expect(wafParams.WafPolicy).NotTo(BeNil()) + Expect(wafParams.WafPolicy.ID).NotTo(BeNil()) + Expect(*wafParams.WafPolicy.ID).To(Equal(wafID)) + Expect(wafParams.Associations).To(HaveLen(1)) + Expect(wafParams.Associations[0].Domains).To(HaveLen(1)) + Expect(wafParams.Associations[0].Domains[0].ID).NotTo(BeNil()) + // Endpoint ID uses the fake's synthetic format so we can + // assert exact equality (proves the reconciler propagated the + // Get response's ID into the SecurityPolicy Association). + expectedEndpointID := fmt.Sprintf(fakeprovider.EndpointResourceIDFormat, + fakeprovider.DefaultSubscriptionID, fakeprovider.DefaultResourceGroupName, + AzureProfileName(got), AzureEndpointName(got)) + Expect(*wafParams.Associations[0].Domains[0].ID).To(Equal(expectedEndpointID)) + }) + }) + + Context("WAF NotFound — reference an unseeded policy", func() { + const profileName = "test-waf-notfound" + + AfterEach(func() { deleteProfile(profileName) }) + + It("should surface Programmed=False with Reason=WAFPolicyNotFound", func() { + // Deliberately DO NOT seed the referenced policy so the WAF + // resolver's Get returns 404. Uses a distinctive name so a + // future test that DOES seed cannot collide. + wafID := fakeprovider.FormatWAFPolicyResourceID(fakeprovider.DefaultResourceGroupName, "waf-never-seeded") + profile := &fleetnetv1alpha1.FrontDoorProfile{ + ObjectMeta: metav1.ObjectMeta{Name: profileName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorProfileSpec{ + ResourceGroup: fakeprovider.DefaultResourceGroupName, + Sku: fleetnetv1alpha1.FrontDoorProfileSkuPremium, + WAFPolicy: &fleetnetv1alpha1.FrontDoorWAFPolicyRef{ResourceID: wafID}, + }, + } + Expect(k8sClient.Create(ctx, profile)).To(Succeed()) + + By("waiting for Programmed=False with Reason=WAFPolicyNotFound") + Eventually(func(g Gomega) { + got := &fleetnetv1alpha1.FrontDoorProfile{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: profileName}, got)).To(Succeed()) + + cond := meta.FindStatusCondition(got.Status.Conditions, + string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed)) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(string(fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotFound))) + g.Expect(cond.Message).To(ContainSubstring(wafID), + "WAFPolicyNotFound message should include the exact ARM ID so operators can diff spec vs Azure") + }, eventuallyTimeout, eventuallyInterval).Should(Succeed()) + }) + }) + + Context("WAF SFI-NS253 rejects Detection-mode policy", func() { + const ( + profileName = "test-waf-sfi-detection" + wafRG = fakeprovider.DefaultResourceGroupName + wafName = "waf-detection" + ) + + BeforeEach(func() { + // Seed a valid (existent) policy but in Detection mode. This + // exercises the SFI-specific tightening: without SFI this + // would be accepted; with SFI it must be rejected because + // Detection-mode WAF only logs, it does not block, which + // violates the NS253 "block by default" posture. + wafPolicyFake.SetPolicy(wafRG, wafName, armfrontdoor.PolicyModeDetection) + }) + + AfterEach(func() { + deleteProfile(profileName) + wafPolicyFake.DeletePolicy(wafRG, wafName) + }) + + It("should surface Programmed=False with Reason=WAFPolicyNotInPreventionMode", func() { + wafID := fakeprovider.FormatWAFPolicyResourceID(wafRG, wafName) + profile := &fleetnetv1alpha1.FrontDoorProfile{ + ObjectMeta: metav1.ObjectMeta{Name: profileName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorProfileSpec{ + ResourceGroup: fakeprovider.DefaultResourceGroupName, + Sku: fleetnetv1alpha1.FrontDoorProfileSkuPremium, + ComplianceMode: fleetnetv1alpha1.FrontDoorProfileComplianceModeSFINS253, + WAFPolicy: &fleetnetv1alpha1.FrontDoorWAFPolicyRef{ResourceID: wafID}, + }, + } + Expect(k8sClient.Create(ctx, profile)).To(Succeed()) + + By("waiting for Programmed=False with Reason=WAFPolicyNotInPreventionMode") + Eventually(func(g Gomega) { + got := &fleetnetv1alpha1.FrontDoorProfile{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: profileName}, got)).To(Succeed()) + + cond := meta.FindStatusCondition(got.Status.Conditions, + string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed)) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(string(fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotInPreventionMode))) + g.Expect(cond.Message).To(ContainSubstring("Prevention"), + "message should name the required mode so operators know exactly what to fix") + + // Sanity: no SecurityPolicy should have been written — the + // reconciler short-circuits before the attach step on this + // rejection. Asserts we do not leak a partial WAF binding + // with a Detection-mode policy behind it. + secName := fmt.Sprintf("fleet-waf-%s", got.UID) + _, ok := securityPolicyFake.GetStored(AzureProfileName(got), secName) + g.Expect(ok).To(BeFalse(), "no SecurityPolicy should be attached when SFI rejects the WAF policy") + }, eventuallyTimeout, eventuallyInterval).Should(Succeed()) + }) + }) +}) diff --git a/pkg/controllers/hub/frontdoorprofile/suite_test.go b/pkg/controllers/hub/frontdoorprofile/suite_test.go new file mode 100644 index 00000000..0c6fe907 --- /dev/null +++ b/pkg/controllers/hub/frontdoorprofile/suite_test.go @@ -0,0 +1,134 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package frontdoorprofile + +import ( + "context" + "flag" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/test/common/azurefrontdoor/fakeprovider" +) + +// Test-suite bootstrap for the FrontDoorProfile controller. Mirrors the ATM +// trafficmanagerprofile suite_test.go so contributors reading either side see +// the same shape: envtest brings up etcd+kube-apiserver, controller-runtime +// wires the Reconciler with fake armcdn clients, and one Ginkgo spec runs +// against the real cache/informer stack (not a fakeclient). + +var ( + cfg *rest.Config + mgr manager.Manager + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc + wafPolicyFake *fakeprovider.WAFPolicyFake + securityPolicyFake *fakeprovider.SecurityPolicyFake +) + +var testNamespace = fakeprovider.ProfileNamespace + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "FrontDoorProfile Controller Suite") +} + +var _ = BeforeSuite(func() { + logger := zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)) + klog.SetLogger(logger) + log.SetLogger(logger) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("../../../../", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + Expect(fleetnetv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + + By("constructing the k8s client") + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + By("starting the controller manager") + klog.InitFlags(flag.CommandLine) + flag.Parse() + + mgr, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + profilesClient, err := fakeprovider.NewProfileClient() + Expect(err).To(Succeed(), "failed to create fake AFD profiles client") + + endpointsClient, err := fakeprovider.NewAFDEndpointClient() + Expect(err).To(Succeed(), "failed to create fake AFD endpoints client") + + wafPolicyFake, err = fakeprovider.NewWAFPolicyFake() + Expect(err).To(Succeed(), "failed to create fake WAF policies client") + + securityPolicyFake, err = fakeprovider.NewSecurityPolicyFake() + Expect(err).To(Succeed(), "failed to create fake AFD security-policies client") + + Expect((&Reconciler{ + Client: mgr.GetClient(), + ProfilesClient: profilesClient, + EndpointsClient: endpointsClient, + WAFPoliciesClient: wafPolicyFake.Client, + SecurityPoliciesClient: securityPolicyFake.Client, + SubscriptionID: fakeprovider.DefaultSubscriptionID, + Recorder: mgr.GetEventRecorderFor(ControllerName), + }).SetupWithManager(mgr)).To(Succeed()) + + By("creating the profile namespace") + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}} + Expect(k8sClient.Create(ctx, &ns)).To(Succeed()) + + go func() { + defer GinkgoRecover() + Expect(mgr.Start(ctx)).To(Succeed(), "failed to run manager") + }() +}) + +var _ = AfterSuite(func() { + defer klog.Flush() + + By("deleting the profile namespace") + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}} + Expect(k8sClient.Delete(ctx, &ns)).To(Succeed()) + + cancel() + By("tearing down the test environment") + Expect(testEnv.Stop()).To(Succeed()) +}) diff --git a/pkg/controllers/hub/frontdoorprofile/waf.go b/pkg/controllers/hub/frontdoorprofile/waf.go new file mode 100644 index 00000000..13d56509 --- /dev/null +++ b/pkg/controllers/hub/frontdoorprofile/waf.go @@ -0,0 +1,311 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package frontdoorprofile + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/frontdoor/armfrontdoor" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "k8s.io/utils/ptr" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/pkg/common/azureerrors" +) + +// WAF-enforcement path for the FrontDoorProfile reconciler. +// +// Scope of enforcement (matches docs/first-party/002-afd-implementation-plan.md §11): +// - spec.wafPolicy nil AND ComplianceMode=None → no WAF, no SecurityPolicy +// attach, and any prior fleet-managed SecurityPolicy is cleaned up (drift +// removal). Programmed=True. +// - spec.wafPolicy set → resolve the referenced WAF policy; upsert a +// SecurityPolicy of type WebApplicationFirewall on the profile that binds +// the policy to the default endpoint (path "/*"). Programmed=True. +// - ComplianceMode=SFI-NS253 → additionally require the referenced policy's +// PolicySettings.Mode == "Prevention". A "Detection"-mode policy is +// rejected with Reason=WAFPolicyNotInPreventionMode so an SFI-audited +// tenant cannot ship a WAF policy that only observes traffic. +// +// The spec-level requirement of "WAFPolicy is set when ComplianceMode is +// SFI-NS253" is enforced by a top-level CEL rule on the CRD (see +// api/v1alpha1/frontdoorprofile_types.go), so this file assumes an SFI +// profile always has spec.wafPolicy != nil and does not re-check. +// +// POC gaps (see breadcrumb 2026-07-20 Addendum 2 / Commit 7 series): +// - Cross-subscription WAF references are rejected with +// Reason=WAFPolicyNotFound + a diagnostic message. Supporting them +// requires a per-subscription armfrontdoor.PoliciesClient which the +// Clients bundle does not construct today; the design is captured in the +// proposal but not yet implemented. +// - Additional AFD custom domains (once FrontDoorCustomDomain lands under +// a full binding controller) are NOT added to the SecurityPolicy +// Associations here — only the default *.azurefd.net endpoint is bound. +// Custom-domain binding is Phase 4 work. +// - The SecurityPolicy Association's PatternsToMatch is fixed to "/*". +// Path-level exclusions are not yet exposed on the CR. + +// wafPolicyResourceIDRegex parses a WAF-policy ARM ID into (sub, rg, name). +// Kept in sync with the CRD-level Pattern validator on +// FrontDoorWAFPolicyRef.ResourceID so a value that passes CRD admission also +// parses here. Anchored to avoid partial matches on odd inputs. +var wafPolicyResourceIDRegex = regexp.MustCompile( + `^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\.Network/frontdoorwebapplicationfirewallpolicies/([^/]+)$`, +) + +// fleetSecurityPolicyName returns the deterministic name of the AFD +// SecurityPolicy that the profile reconciler owns for a given FrontDoorProfile. +// Uses the CR UID (same convention as AzureProfileName / AzureEndpointName) +// so the underlying Azure name survives CR renames and never collides across +// CRs. The "fleet-waf-" prefix makes the resource identifiable as +// fleet-managed when browsing the AFD portal. +func fleetSecurityPolicyName(profile *fleetnetv1alpha1.FrontDoorProfile) string { + return fmt.Sprintf("fleet-waf-%s", profile.UID) +} + +// wafResolveResult captures the parsed pieces of the WAF-policy ARM ID plus +// the resolved policy for downstream consumers (validation + attach). Kept +// as a struct rather than multiple return values because callers commonly +// need everything together. +type wafResolveResult struct { + subscriptionID string + resourceGroup string + policyName string + policyID string // canonical ARM ID (same as spec.wafPolicy.resourceID). + policy armfrontdoor.WebApplicationFirewallPolicy +} + +// ensureWAFEnforcement is the single entry point invoked from handleUpdate +// after the profile + default endpoint are in place. It returns a bool +// indicating whether Programmed=True can proceed: +// - true, nil → WAF state is settled (either attached correctly, or +// absent and any prior attach has been cleaned up); caller may set +// Programmed=True. +// - false, nil → a non-Programmed condition has already been written +// (WAFPolicyNotFound / WAFPolicyNotInPreventionMode / an Azure error +// surfaced via reportAzureError); caller should NOT overwrite it and +// should return early. The reconcile will be re-driven when the WAF +// policy is created/updated (spec change on the profile CR) or on the +// next resync tick. +// - false, err → a transient Azure error occurred; caller should return +// the error so the workqueue retries with backoff. +// +// endpointARMID must be the fully qualified ARM ID of the default AFD +// endpoint (needed for the SecurityPolicy Association). It is passed in +// rather than re-derived because handleUpdate already Get/Created the +// endpoint and has the value in hand. +func (r *Reconciler) ensureWAFEnforcement(ctx context.Context, profile *fleetnetv1alpha1.FrontDoorProfile, endpointARMID string) (bool, error) { + profileKObj := klog.KObj(profile) + azProfileName := AzureProfileName(profile) + secPolicyName := fleetSecurityPolicyName(profile) + + // Fast path: no WAF requested. Best-effort drift removal so a user who + // clears spec.wafPolicy does not leave an orphaned SecurityPolicy on + // the AFD profile pointing at a WAF policy the CR no longer references. + if profile.Spec.WAFPolicy == nil { + return r.ensureNoSecurityPolicy(ctx, profile, secPolicyName) + } + + res, ok, err := r.resolveWAFPolicy(ctx, profile) + if err != nil { + // Azure error already surfaced via reportAzureError; the caller must + // return err so the workqueue retries. Programmed=True must not be + // set in this reconcile. + return false, err + } + if !ok { + // Terminal client-side rejection (NotFound / bad ID / cross-sub). + // Condition was written inside resolveWAFPolicy. + return false, nil + } + + // SFI-NS253 tightening: prevention mode is mandatory. Detection or a + // nil-Mode policy would let malicious traffic through while only being + // logged, violating the "block by default" posture SFI requires. + if profile.Spec.ComplianceMode == fleetnetv1alpha1.FrontDoorProfileComplianceModeSFINS253 { + mode := "" + if res.policy.Properties != nil && res.policy.Properties.PolicySettings != nil && res.policy.Properties.PolicySettings.Mode != nil { + mode = string(*res.policy.Properties.PolicySettings.Mode) + } + if mode != string(armfrontdoor.PolicyModePrevention) { + r.setWAFCondition(ctx, profile, + fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotInPreventionMode, + fmt.Sprintf("SFI-NS253 requires WAF policy %s to be in Prevention mode; found %q", res.policyID, mode)) + r.Recorder.Eventf(profile, corev1.EventTypeWarning, string(fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotInPreventionMode), + "WAF policy %s is not in Prevention mode (SFI-NS253)", res.policyID) + return false, nil + } + } + + // Upsert the SecurityPolicy binding the WAF policy to the default + // endpoint. armcdn's BeginCreate is an upsert (no separate Update + // endpoint exists on the SecurityPolicy resource), so calling it on + // every reconcile is safe and drives Azure to the desired state even + // if a user edits the SecurityPolicy out-of-band. + desired := desiredSecurityPolicy(res.policyID, endpointARMID) + poller, err := r.SecurityPoliciesClient.BeginCreate(ctx, profile.Spec.ResourceGroup, azProfileName, secPolicyName, desired, nil) + if err != nil { + _, retErr := r.reportAzureError(ctx, profile, "begin create security policy", err) + return false, retErr + } + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + _, retErr := r.reportAzureError(ctx, profile, "create security policy", err) + return false, retErr + } + klog.V(2).InfoS("Ensured WAF SecurityPolicy attach", + "frontDoorProfile", profileKObj, "securityPolicy", secPolicyName, "wafPolicy", res.policyID) + return true, nil +} + +// ensureNoSecurityPolicy removes any previously-attached fleet-managed +// SecurityPolicy. Called when spec.wafPolicy is nil so a user clearing the +// field does not leave stale WAF attachment behind. NotFound from the Get is +// the common case (no prior attach) and is not an error. +func (r *Reconciler) ensureNoSecurityPolicy(ctx context.Context, profile *fleetnetv1alpha1.FrontDoorProfile, secPolicyName string) (bool, error) { + profileKObj := klog.KObj(profile) + azProfileName := AzureProfileName(profile) + + if _, err := r.SecurityPoliciesClient.Get(ctx, profile.Spec.ResourceGroup, azProfileName, secPolicyName, nil); err != nil { + if azureerrors.IsNotFound(err) { + return true, nil // Nothing to clean up; happy path. + } + _, retErr := r.reportAzureError(ctx, profile, "get security policy (cleanup probe)", err) + return false, retErr + } + + // Existing SecurityPolicy found and spec no longer wants one → delete. + poller, err := r.SecurityPoliciesClient.BeginDelete(ctx, profile.Spec.ResourceGroup, azProfileName, secPolicyName, nil) + if err != nil { + if azureerrors.IsNotFound(err) { + return true, nil + } + _, retErr := r.reportAzureError(ctx, profile, "begin delete security policy (drift removal)", err) + return false, retErr + } + if _, err := poller.PollUntilDone(ctx, nil); err != nil { + if azureerrors.IsNotFound(err) { + return true, nil + } + _, retErr := r.reportAzureError(ctx, profile, "delete security policy (drift removal)", err) + return false, retErr + } + klog.V(2).InfoS("Removed stale WAF SecurityPolicy after spec.wafPolicy cleared", + "frontDoorProfile", profileKObj, "securityPolicy", secPolicyName) + return true, nil +} + +// resolveWAFPolicy parses spec.wafPolicy.resourceID and Gets the referenced +// policy from Azure. Returns: +// - (res, true, nil) on success +// - (_, false, nil) on terminal client rejection (invalid ID / cross-sub +// / NotFound). The appropriate condition is written before returning. +// - (_, false, err) on transient Azure error. The AzureError condition +// is written via reportAzureError; caller must return err to retry. +func (r *Reconciler) resolveWAFPolicy(ctx context.Context, profile *fleetnetv1alpha1.FrontDoorProfile) (wafResolveResult, bool, error) { + raw := profile.Spec.WAFPolicy.ResourceID + match := wafPolicyResourceIDRegex.FindStringSubmatch(raw) + if match == nil { + // Should be unreachable in practice — the CRD Pattern validator + // rejects malformed IDs at admission — but guard defensively so a + // version-skew (older API server without the Pattern) does not + // crash the reconciler. + r.setWAFCondition(ctx, profile, + fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotFound, + fmt.Sprintf("spec.wafPolicy.resourceID %q is not a valid AFD WAF policy ARM ID", raw)) + return wafResolveResult{}, false, nil + } + sub, rg, name := match[1], match[2], match[3] + + // Cross-subscription references would need a separate armfrontdoor + // PoliciesClient constructed against `sub`, which the current Clients + // bundle (pkg/common/azurefrontdoor/client.go) does not build. Rather + // than silently failing later with an opaque 403, surface the limit + // clearly so the user knows to co-locate the WAF policy for the POC. + if !strings.EqualFold(sub, r.subscriptionID()) { + r.setWAFCondition(ctx, profile, + fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotFound, + fmt.Sprintf("cross-subscription WAF references are not yet supported (policy sub %s, profile sub %s)", sub, r.subscriptionID())) + return wafResolveResult{}, false, nil + } + + resp, err := r.WAFPoliciesClient.Get(ctx, rg, name, nil) + if err != nil { + if azureerrors.IsNotFound(err) { + r.setWAFCondition(ctx, profile, + fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotFound, + fmt.Sprintf("WAF policy %s not found", raw)) + r.Recorder.Eventf(profile, corev1.EventTypeWarning, string(fleetnetv1alpha1.FrontDoorProfileReasonWAFPolicyNotFound), + "WAF policy %s not found", raw) + return wafResolveResult{}, false, nil + } + _, retErr := r.reportAzureError(ctx, profile, "get waf policy", err) + return wafResolveResult{}, false, retErr + } + + return wafResolveResult{ + subscriptionID: sub, + resourceGroup: rg, + policyName: name, + policyID: raw, + policy: resp.WebApplicationFirewallPolicy, + }, true, nil +} + +// setWAFCondition writes a non-Programmed Programmed-type condition capturing +// a terminal WAF rejection (not a transient Azure error — those flow through +// reportAzureError). Uses Programmed=False so status.conditions has a single +// authoritative condition type; consumers watching for readiness look at +// Programmed only. +func (r *Reconciler) setWAFCondition(ctx context.Context, profile *fleetnetv1alpha1.FrontDoorProfile, reason fleetnetv1alpha1.FrontDoorProfileConditionReason, message string) { + meta.SetStatusCondition(&profile.Status.Conditions, metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: profile.Generation, + Reason: string(reason), + Message: message, + }) + if err := r.Client.Status().Update(ctx, profile); err != nil { + klog.ErrorS(err, "Failed to update frontDoorProfile status after WAF rejection", + "frontDoorProfile", klog.KObj(profile), "reason", reason) + } +} + +// subscriptionID returns the ARM subscription the WAF PoliciesClient targets. +// The SDK client does not expose this publicly, so we cache it in the +// Reconciler (see SubscriptionID field). Kept as a method rather than a +// direct field access so a future indirection (e.g. multi-sub) is easy. +func (r *Reconciler) subscriptionID() string { + return r.SubscriptionID +} + +// desiredSecurityPolicy builds the armcdn.SecurityPolicy payload binding +// the resolved WAF policy to the default AFD endpoint. Path "/*" catches all +// requests; per-path exclusions are not yet exposed on the CR. +func desiredSecurityPolicy(wafPolicyID, endpointARMID string) armcdn.SecurityPolicy { + return armcdn.SecurityPolicy{ + Properties: &armcdn.SecurityPolicyProperties{ + Parameters: &armcdn.SecurityPolicyWebApplicationFirewallParameters{ + Type: ptr.To(armcdn.SecurityPolicyTypeWebApplicationFirewall), + WafPolicy: &armcdn.ResourceReference{ + ID: ptr.To(wafPolicyID), + }, + Associations: []*armcdn.SecurityPolicyWebApplicationFirewallAssociation{{ + Domains: []*armcdn.ActivatedResourceReference{{ + ID: ptr.To(endpointARMID), + }}, + PatternsToMatch: []*string{ptr.To("/*")}, + }}, + }, + }, + } +} diff --git a/test/common/azurefrontdoor/fakeprovider/profile.go b/test/common/azurefrontdoor/fakeprovider/profile.go new file mode 100644 index 00000000..d6780460 --- /dev/null +++ b/test/common/azurefrontdoor/fakeprovider/profile.go @@ -0,0 +1,174 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package fakeprovider provides a fake Azure implementation of the Front Door +// (armcdn/v2) sub-clients used by the AFD controllers. The package is +// deliberately small: it only exposes the surface (Get, BeginCreate, +// BeginDelete) actually invoked by the reconcilers today. Additional operations +// (Update, ListByResourceGroup, etc.) should be added on demand when +// controllers grow to use them. +// +// The fakes are STATEFUL: create-then-get returns the previously-created +// resource, and delete-then-get returns NotFound. This mirrors real Azure +// semantics closely enough that reconcilers exercising the "get before create" +// idempotency pattern behave the same in envtest as in production. +// +// Error injection uses magic resource names (see the *Err* constants below), +// matching the convention in test/common/trafficmanager/fakeprovider. +package fakeprovider + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcorefake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2/fake" + "k8s.io/utils/ptr" +) + +const ( + // DefaultSubscriptionID is the subscription used when constructing the fake + // client factory. Tests requesting a different subscription will receive a + // Forbidden response (see the guard in each handler). + DefaultSubscriptionID = "default-subscription-id" + // DefaultResourceGroupName is the resource group all valid fake resources + // live in. Any other RG name yields Forbidden. + DefaultResourceGroupName = "default-resource-group-name" + // ProfileNamespace is the Kubernetes namespace used by suite_test.go setups + // for FrontDoorProfile / FrontDoorCustomDomain CRs. Matches the ATM + // convention (test/common/trafficmanager/fakeprovider.ProfileNamespace) so + // integration tests read symmetrically. + ProfileNamespace = "afd-profile-ns" + + // ProfileResourceIDFormat is the ARM ID format for an AFD profile. + // Kept as a package-level constant so tests can assert on + // FrontDoorProfileStatus.ResourceID without duplicating the layout. + ProfileResourceIDFormat = "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s" + + // Magic profile names that trigger canned error paths in the fake. These + // exercise the reconciler's error-classification branches without needing + // an out-of-band control channel. + ConflictErrProfileName = "conflict-err-profile" + InternalServerErrProfileName = "internal-server-err-profile" +) + +// profileState is the in-memory record backing the fake. Only the fields the +// reconciler reads back via Get / status update are tracked; anything else can +// be added when a reconciler starts to consume it. +type profileState struct { + profile armcdn.Profile +} + +// profileStore is the shared state across all fake ProfilesServer handlers for +// a single NewProfileClient call. A fresh store per client keeps tests isolated +// (BeforeSuite constructs one client, so all specs in a suite share state — the +// same isolation model ATM uses). +type profileStore struct { + mu sync.Mutex + profiles map[string]*profileState +} + +func newProfileStore() *profileStore { + return &profileStore{profiles: map[string]*profileState{}} +} + +// NewProfileClient creates an armcdn.ProfilesClient backed by an in-memory +// fake. Each call returns an INDEPENDENT client with its own state store, so +// tests do not need to reset state between runs. +func NewProfileClient() (*armcdn.ProfilesClient, error) { + store := newProfileStore() + + srv := fake.ProfilesServer{ + Get: store.get, + BeginCreate: store.beginCreate, + BeginDelete: store.beginDelete, + } + factory, err := armcdn.NewClientFactory(DefaultSubscriptionID, &azcorefake.TokenCredential{}, + &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: fake.NewProfilesServerTransport(&srv), + }, + }) + if err != nil { + return nil, err + } + return factory.NewProfilesClient(), nil +} + +func (s *profileStore) get(_ context.Context, resourceGroupName string, profileName string, _ *armcdn.ProfilesClientGetOptions) (resp azcorefake.Responder[armcdn.ProfilesClientGetResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + defer s.mu.Unlock() + + st, ok := s.profiles[profileName] + if !ok { + // AFD's real "profile does not exist" response — the reconciler's + // azureerrors.IsNotFound branch depends on this shape. + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp + } + resp.SetResponse(http.StatusOK, armcdn.ProfilesClientGetResponse{Profile: st.profile}, nil) + return resp, errResp +} + +func (s *profileStore) beginCreate(_ context.Context, resourceGroupName string, profileName string, parameters armcdn.Profile, _ *armcdn.ProfilesClientBeginCreateOptions) (resp azcorefake.PollerResponder[armcdn.ProfilesClientCreateResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + // Error-injection branches: return immediately, do NOT store state. + switch profileName { + case ConflictErrProfileName: + resp.SetTerminalError(http.StatusConflict, "Conflict") + return resp, errResp + case InternalServerErrProfileName: + resp.SetTerminalError(http.StatusInternalServerError, "InternalServerError") + return resp, errResp + } + + // Happy path: copy the payload, patch in the ARM ID (which real AFD + // assigns), and remember it so subsequent Get calls succeed. + created := parameters + created.Name = ptr.To(profileName) + created.ID = ptr.To(fmt.Sprintf(ProfileResourceIDFormat, DefaultSubscriptionID, DefaultResourceGroupName, profileName)) + if created.Properties == nil { + created.Properties = &armcdn.ProfileProperties{} + } + + s.mu.Lock() + s.profiles[profileName] = &profileState{profile: created} + s.mu.Unlock() + + resp.SetTerminalResponse(http.StatusOK, armcdn.ProfilesClientCreateResponse{Profile: created}, nil) + return resp, errResp +} + +func (s *profileStore) beginDelete(_ context.Context, resourceGroupName string, profileName string, _ *armcdn.ProfilesClientBeginDeleteOptions) (resp azcorefake.PollerResponder[armcdn.ProfilesClientDeleteResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + _, ok := s.profiles[profileName] + delete(s.profiles, profileName) + s.mu.Unlock() + + if !ok { + // Reconciler treats 404 on delete as "already gone" (idempotent + // cleanup). Returning it here validates that path. + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp + } + resp.SetTerminalResponse(http.StatusOK, armcdn.ProfilesClientDeleteResponse{}, nil) + return resp, errResp +} diff --git a/test/common/azurefrontdoor/fakeprovider/securitypolicy.go b/test/common/azurefrontdoor/fakeprovider/securitypolicy.go new file mode 100644 index 00000000..8a3c29ab --- /dev/null +++ b/test/common/azurefrontdoor/fakeprovider/securitypolicy.go @@ -0,0 +1,144 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package fakeprovider + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcorefake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2/fake" + "k8s.io/utils/ptr" +) + +// SecurityPolicyResourceIDFormat is the ARM ID format for an AFD SecurityPolicy +// child resource. The reconciler does not read this back today (it only +// asserts existence), but it is populated on the stored fake so tests that +// want to assert the fully qualified ID have a stable format to compare +// against. +const SecurityPolicyResourceIDFormat = "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/securityPolicies/%s" + +// securityPolicyKey scopes state by (profile, name) so multiple concurrent +// profiles in the same fake do not collide. Cross-RG collisions cannot happen +// because the AFD SecurityPolicy resource is always a child of a profile that +// itself lives in one RG. +type securityPolicyKey struct { + profile, name string +} + +type securityPolicyStore struct { + mu sync.Mutex + policies map[securityPolicyKey]armcdn.SecurityPolicy +} + +// SecurityPolicyFake wraps the fake SecurityPoliciesClient together with an +// inspection handle so specs can assert on what the reconciler stored. +// Unlike the WAF policy fake, this store is written entirely by the +// reconciler (via BeginCreate/BeginDelete) — specs only READ from it via +// GetStored. Seeding is not exposed because there is no scenario where the +// reconciler should observe a SecurityPolicy it did not itself write. +type SecurityPolicyFake struct { + Client *armcdn.SecurityPoliciesClient + store *securityPolicyStore +} + +// NewSecurityPolicyFake returns a fresh SecurityPolicyFake with an empty +// store. Specs typically construct one per suite (BeforeSuite) and reset by +// deleting the FrontDoorProfile CR in AfterEach — the reconciler cleans up +// via its finalizer, which in turn calls Delete on this fake, so state does +// not leak between specs. +func NewSecurityPolicyFake() (*SecurityPolicyFake, error) { + store := &securityPolicyStore{policies: map[securityPolicyKey]armcdn.SecurityPolicy{}} + srv := fake.SecurityPoliciesServer{ + Get: store.get, + BeginCreate: store.beginCreate, + BeginDelete: store.beginDelete, + } + factory, err := armcdn.NewClientFactory(DefaultSubscriptionID, &azcorefake.TokenCredential{}, + &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: fake.NewSecurityPoliciesServerTransport(&srv), + }, + }) + if err != nil { + return nil, err + } + return &SecurityPolicyFake{Client: factory.NewSecurityPoliciesClient(), store: store}, nil +} + +// GetStored returns a snapshot of what the reconciler last wrote for the +// (profile, name) pair. Second return is false if nothing was ever written +// (or it was deleted). Intended for spec assertions such as "WafPolicy.ID +// matches the expected ARM ID" or "Associations contains the endpoint". +func (s *SecurityPolicyFake) GetStored(profile, name string) (armcdn.SecurityPolicy, bool) { + s.store.mu.Lock() + defer s.store.mu.Unlock() + p, ok := s.store.policies[securityPolicyKey{profile, name}] + return p, ok +} + +func (s *securityPolicyStore) get(_ context.Context, resourceGroupName string, profileName string, securityPolicyName string, _ *armcdn.SecurityPoliciesClientGetOptions) (resp azcorefake.Responder[armcdn.SecurityPoliciesClientGetResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + defer s.mu.Unlock() + + p, ok := s.policies[securityPolicyKey{profileName, securityPolicyName}] + if !ok { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp + } + resp.SetResponse(http.StatusOK, armcdn.SecurityPoliciesClientGetResponse{SecurityPolicy: p}, nil) + return resp, errResp +} + +func (s *securityPolicyStore) beginCreate(_ context.Context, resourceGroupName string, profileName string, securityPolicyName string, securityPolicy armcdn.SecurityPolicy, _ *armcdn.SecurityPoliciesClientBeginCreateOptions) (resp azcorefake.PollerResponder[armcdn.SecurityPoliciesClientCreateResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + created := securityPolicy + created.Name = ptr.To(securityPolicyName) + created.ID = ptr.To(fmt.Sprintf(SecurityPolicyResourceIDFormat, DefaultSubscriptionID, DefaultResourceGroupName, profileName, securityPolicyName)) + + s.mu.Lock() + // BeginCreate is treated as upsert here — matches SDK behavior where a + // repeated create with the same name overwrites the existing resource + // (AFD SecurityPolicy has no separate Update endpoint). + s.policies[securityPolicyKey{profileName, securityPolicyName}] = created + s.mu.Unlock() + + resp.SetTerminalResponse(http.StatusOK, armcdn.SecurityPoliciesClientCreateResponse{SecurityPolicy: created}, nil) + return resp, errResp +} + +func (s *securityPolicyStore) beginDelete(_ context.Context, resourceGroupName string, profileName string, securityPolicyName string, _ *armcdn.SecurityPoliciesClientBeginDeleteOptions) (resp azcorefake.PollerResponder[armcdn.SecurityPoliciesClientDeleteResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + _, ok := s.policies[securityPolicyKey{profileName, securityPolicyName}] + delete(s.policies, securityPolicyKey{profileName, securityPolicyName}) + s.mu.Unlock() + + if !ok { + // Real Azure returns 404 for delete of a non-existent SecurityPolicy; + // the reconciler's drift-cleanup path treats 404 as "already gone" + // so this is the correct fake shape. + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp + } + resp.SetTerminalResponse(http.StatusOK, armcdn.SecurityPoliciesClientDeleteResponse{}, nil) + return resp, errResp +} diff --git a/test/common/azurefrontdoor/fakeprovider/wafpolicy.go b/test/common/azurefrontdoor/fakeprovider/wafpolicy.go new file mode 100644 index 00000000..5d7203e8 --- /dev/null +++ b/test/common/azurefrontdoor/fakeprovider/wafpolicy.go @@ -0,0 +1,150 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package fakeprovider + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcorefake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/frontdoor/armfrontdoor" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/frontdoor/armfrontdoor/fake" + "k8s.io/utils/ptr" +) + +// WAFPolicyResourceIDFormat is the ARM ID format for an AFD WAF policy. The +// classic Front Door WAF policy type lives under Microsoft.Network (NOT under +// Microsoft.Cdn), which is why it requires a separate SDK (armfrontdoor) +// from the AFD Standard/Premium profile/endpoint APIs (armcdn/v2). +const WAFPolicyResourceIDFormat = "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/frontdoorwebapplicationfirewallpolicies/%s" + +// FormatWAFPolicyResourceID builds a fully qualified WAF policy ARM ID against +// the fake's DefaultSubscriptionID. Tests should use this so the string they +// put into FrontDoorProfileSpec.WAFPolicy.ResourceID exactly matches what the +// fake will resolve — the reconciler splits the string by ARM segments and any +// drift would cause a spurious NotFound. +func FormatWAFPolicyResourceID(resourceGroup, name string) string { + return fmt.Sprintf(WAFPolicyResourceIDFormat, DefaultSubscriptionID, resourceGroup, name) +} + +// wafPolicyKey scopes state by (rg, name) so a single fake can host policies +// across multiple resource groups if a test wants to exercise cross-RG refs. +// Cross-subscription refs are NOT modeled — the fake ignores the subscription +// segment (there is only DefaultSubscriptionID) and the reconciler currently +// rejects cross-sub WAF references at parse time. +type wafPolicyKey struct { + rg, name string +} + +type wafPolicyStore struct { + mu sync.Mutex + policies map[wafPolicyKey]armfrontdoor.WebApplicationFirewallPolicy +} + +// WAFPolicyFake wraps the fake PoliciesClient together with a seeding handle. +// Tests seed policies via SetPolicy BEFORE the reconciler runs (i.e. before +// the FrontDoorProfile CR is created) so that the reconciler's very first +// Get sees the intended state. The reconciler itself never mutates WAF +// policies — it only reads — so the store is effectively test-owned. +type WAFPolicyFake struct { + Client *armfrontdoor.PoliciesClient + store *wafPolicyStore +} + +// NewWAFPolicyFake returns a fresh WAFPolicyFake with an empty policy store. +// Callers typically construct this once per suite (BeforeSuite) and share the +// same instance across specs; specs seed/clear policies as they need. +func NewWAFPolicyFake() (*WAFPolicyFake, error) { + store := &wafPolicyStore{policies: map[wafPolicyKey]armfrontdoor.WebApplicationFirewallPolicy{}} + srv := fake.PoliciesServer{ + Get: store.get, + BeginCreateOrUpdate: store.beginCreateOrUpdate, + BeginDelete: store.beginDelete, + } + factory, err := armfrontdoor.NewClientFactory(DefaultSubscriptionID, &azcorefake.TokenCredential{}, + &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: fake.NewPoliciesServerTransport(&srv), + }, + }) + if err != nil { + return nil, err + } + return &WAFPolicyFake{Client: factory.NewPoliciesClient(), store: store}, nil +} + +// SetPolicy seeds a WAF policy in the fake store at the given (rg, name) with +// the requested mode (Prevention/Detection). Called from spec setup so the +// reconciler's Get resolves the policy AND observes the mode the spec wants +// to exercise (e.g. Detection to trigger WAFPolicyNotInPreventionMode). +// EnabledState is set to Enabled because a Disabled policy is not a scenario +// the reconciler currently distinguishes. +func (w *WAFPolicyFake) SetPolicy(rg, name string, mode armfrontdoor.PolicyMode) { + w.store.mu.Lock() + defer w.store.mu.Unlock() + w.store.policies[wafPolicyKey{rg, name}] = armfrontdoor.WebApplicationFirewallPolicy{ + Name: ptr.To(name), + ID: ptr.To(FormatWAFPolicyResourceID(rg, name)), + Properties: &armfrontdoor.WebApplicationFirewallPolicyProperties{ + PolicySettings: &armfrontdoor.PolicySettings{ + Mode: ptr.To(mode), + EnabledState: ptr.To(armfrontdoor.PolicyEnabledStateEnabled), + }, + }, + } +} + +// DeletePolicy removes a previously seeded policy. Useful for specs that want +// to exercise the WAFPolicyNotFound path without recreating the whole fake. +func (w *WAFPolicyFake) DeletePolicy(rg, name string) { + w.store.mu.Lock() + defer w.store.mu.Unlock() + delete(w.store.policies, wafPolicyKey{rg, name}) +} + +func (s *wafPolicyStore) get(_ context.Context, resourceGroupName string, policyName string, _ *armfrontdoor.PoliciesClientGetOptions) (resp azcorefake.Responder[armfrontdoor.PoliciesClientGetResponse], errResp azcorefake.ErrorResponder) { + s.mu.Lock() + defer s.mu.Unlock() + // Case-insensitive RG lookup: real Azure treats ARM resource-group names + // case-insensitively, and the reconciler's parser preserves the case + // from the user-supplied ResourceID. Tests that seed with lowercase and + // reference with mixed case (or vice versa) would otherwise flake. + for k, v := range s.policies { + if strings.EqualFold(k.rg, resourceGroupName) && k.name == policyName { + resp.SetResponse(http.StatusOK, armfrontdoor.PoliciesClientGetResponse{WebApplicationFirewallPolicy: v}, nil) + return resp, errResp + } + } + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp +} + +// beginCreateOrUpdate exists so tests that want to exercise the WAF-write +// side (e.g. a future inline-WAF creation controller) do not need a second +// fake. The reconciler itself never calls this today; it only reads. +func (s *wafPolicyStore) beginCreateOrUpdate(_ context.Context, resourceGroupName string, policyName string, parameters armfrontdoor.WebApplicationFirewallPolicy, _ *armfrontdoor.PoliciesClientBeginCreateOrUpdateOptions) (resp azcorefake.PollerResponder[armfrontdoor.PoliciesClientCreateOrUpdateResponse], errResp azcorefake.ErrorResponder) { + stored := parameters + stored.Name = ptr.To(policyName) + stored.ID = ptr.To(FormatWAFPolicyResourceID(resourceGroupName, policyName)) + s.mu.Lock() + s.policies[wafPolicyKey{resourceGroupName, policyName}] = stored + s.mu.Unlock() + resp.SetTerminalResponse(http.StatusOK, armfrontdoor.PoliciesClientCreateOrUpdateResponse{WebApplicationFirewallPolicy: stored}, nil) + return resp, errResp +} + +func (s *wafPolicyStore) beginDelete(_ context.Context, resourceGroupName string, policyName string, _ *armfrontdoor.PoliciesClientBeginDeleteOptions) (resp azcorefake.PollerResponder[armfrontdoor.PoliciesClientDeleteResponse], errResp azcorefake.ErrorResponder) { + s.mu.Lock() + delete(s.policies, wafPolicyKey{resourceGroupName, policyName}) + s.mu.Unlock() + resp.SetTerminalResponse(http.StatusOK, armfrontdoor.PoliciesClientDeleteResponse{}, nil) + return resp, errResp +}