2. Inside a cluster

2.7 Policy enforcement with the Azure Policy add-on

Requirements: R3, R8, R11, R14, R15, R22, R24, R29.

The AKS-native Azure Policy add-on is the single policy engine, enabled by the cluster IaC in every cluster. It runs Gatekeeper, but AKS installs and upgrades it, and the policies are ordinary Azure Policy definitions and assignments – the same tool that governs the Azure resources (cluster settings, allowed VM sizes, VNet encryption, local auth disabled, private endpoints only). No Gatekeeper is installed by Flux.

  • Assignments per environment and exposure subscription (sub-aks-dev-int, sub-aks-dev-ext, … sub-aks-prd-ext), so a new or rebuilt cluster gets every policy automatically; the zone mutations and validations of a subscription are made for its exposure’s zones only. Compliance of all clusters is visible in one place (Azure Policy, and the company’s security tool - not Microsoft Defender).
  • Built-in definitions first: the Kubernetes pod security restricted initiative, allowed images, required probes and resource requests, and deployment safeguards.
  • Custom definitions (Microsoft.Kubernetes.Data mode) for the platform’s own rules, with the constraint or mutation template embedded as Base64 in the definition. Templates are kept in the IaC repository and tested in CI with gator verify. The templates are Rego; CEL-based definitions (generated as Kubernetes ValidatingAdmissionPolicy, evaluated in-process) are the choice for new validations once Azure Policy supports them for the rule in question.
  • Rollout dev → acc → prd: definitions are versioned; a new or changed definition is assigned with effect audit in dev, then deny, then promoted to acc and prd with the rest of the platform change.
  • Exemptions expire. An Azure Policy exemption always has an owner, a reason, a ticket and an expiresOn date (at most 6 months). The security agent reports exemptions that expire within 30 days, and renewing one is a new pull request in the high tier (section 6.1). Exceptions should stay visible and a little uncomfortable, not pile up silently.
  • Continuous evidence, not a one-time hardening. The compliance state of the assignments is the drift signal. Next to it, the CIS Kubernetes benchmark of the nodes (kube-bench, run as one read-only Job per node, weekly in acc and prd) and the security tool’s posture score per environment go to Grafana Cloud. A drop in either raises an alert to the security agent, which matters more than the absolute score. The CIS AKS benchmark and the NSA/CISA Kubernetes hardening guidance are the reference controls, and the checklist is repeated with every Kubernetes minor upgrade, because new minors can change defaults. The full benchmark – nodes (kube-bench), Kubernetes policies (Kubescape) and the cluster’s Azure settings – also runs as a pipeline stage after every cell change, with every accepted failure listed with its reason (section 0.3).
  • Platform namespaces: kube-system, gatekeeper-system and azure-arc are excluded by the definitions’ defaults; the assignments exclude the platform’s own namespaces (flux-system, external-secrets, external-dns, platform-gateway, infra-observability, aks-command, the AKS add-on namespaces, <zone>-gateway and <zone>-secrets) from the application rules through the namespace exclusion parameters; the secrets rules keep <zone>-gateway in. The zones’ own platform namespaces, <zone>-gateway (the zone’s gateway) and <zone>-secrets (External Secrets Operator), run on the system pool so that a zone without applications has no nodes (section 2.3); they are excluded from zone pinning and the other application rules, and their policies are the platform’s own. The <zone>-gateway namespaces enforce the Pod Security Standard baseline and only audit and warn on restricted: App Routing’s gateway pods set no seccomp profile, and their securityContext cannot be customised.
  • Mutations fill in, validations decide. A mutation only adds what a pod leaves out, so a pod that states its placement differently is not changed but reported (audit) or refused (deny) by the matching validation. A recorded exception is visible the same way: a pod labelled platform/zone-placement: "off" is skipped by the zone mutation and stays non-compliant in the zone validation.
  • Which clusters a rule reaches is set by the assignment, not the definition: a Microsoft.Kubernetes.Data rule can test only a cluster’s type, location and tags. The stateless rules leave out the stateful cluster with notScopes, and the stateful rules leave out the stateless cells. Custom mutations cannot be parameterized, so the zone mutations are one definition per zone, generated by the IaC.

Limits of the add-on and how the design handles them:

Limit Consequence in this design
Needs data.policy.core.windows.net, store.policy.core.windows.net, dc.services.visualstudio.com (+ Entra ID); no Private Link Firewall application rules for these FQDNs, also from the stateful cluster (exception to “no Internet”, see flows)
Custom definitions cannot use Gatekeeper data replication (no lookups of other objects) Zone rules match the namespace name prefix <zone>-*; “PDB exists for every Deployment / StatefulSet” is checked in CI on the rendered manifests, because Flux and the stateful pipeline only apply validated commits
Policies are synced from Azure every 15 minutes The stateless cluster bootstrap keeps the Flux apps Kustomization suspended until all assigned constraint templates and constraints are present in the cluster; the stateful pipeline checks the same before every run
Gatekeeper config cannot be changed No custom Gatekeeper settings; defaults are fine for this design
Max 10 000 pods per cluster Well above the expected size; monitored
The built-in deployment safeguards initiative has a warn-only member that reports every pod a mutation changed (“resource was mutated by platform mutation”) The platform’s mutations change every application pod by design (zone placement from the namespace, architecture, spot), so the assignment disables that member: it is a note, not a control, and the validations that check what the mutations wrote stay. Placement cannot be injected without a mutation unless a second admission engine is added, which R15 rules out

Day-1 set. The first cluster of any environment is accepted only with these rules in deny (they are the acceptance checklist of a new cell): zone placement from the namespace (mutation and validation), platform/arch and platform/capacity mutations, images by digest from the environment’s ACR, Secrets only through ESO and no Secrets Store CSI, no application egress policy out of the cluster, PersistentVolumeClaim rejected in stateless cells, Gateway and HTTPS-only listener rules, h2c on every routed Service port, LoadBalancer and NodePort only in <zone>-gateway, no Service.spec.externalIPs (the built-in “allowed external IPs” rule with an empty list: deprecated in Kubernetes 1.36, removal planned for 1.43, and the oldest service hijack in the book - CVE-2020-8554), requests and probes set, the restricted pod security standard, and the cluster-level rules (Cilium, Azure Linux, local accounts and SSH off, CMK). Everything else in the table starts in audit and is promoted rule by rule.

Policy All clusters Stateless Stateful
Namespace <zone>-* has label platform/zone=<zone>; pod nodeSelector / toleration match the prefix, no other zone’s and not the system pool (mutation + validation) ✔    
platform/arch / platform/capacity labels: inject the architecture nodeSelector and the spot toleration + preferred affinity (mutation); reject unknown values, own kubernetes.io/arch / kubernetes.azure.com/scalesetpriority selectors and spot tolerations without the label (section 2.3) ✔ all values defaults only (amd64, on-demand)
Namespace carries platform/team, platform/cost-center and the annotation platform/contact (all but AKS’s own namespaces; section 2.6) ✔    
Images by digest from the environment’s ACR only ✔    
Images signed by the platform’s CI (a Notation signature with a Key Vault key, stored next to the image in the ACR). Enforced at promotion: the promotion pipeline verifies the signature before it imports an image into the next environment’s ACR, and only that pipeline can write to the acc and prd registries, so an unsigned image never reaches them. Audited at admission with Image Integrity (Ratify on Gatekeeper), which today is preview and supports only the audit effect; it moves to deny when that is supported. Ratify trusts the platform’s certificate by its subject and reads it from kv-<env>-platform, and the signatures from the ACR, as one shared identity per environment ✔    
ServiceAccount used by application pods is not key-vault; no imagePullSecrets on pods or ServiceAccounts (that the ServiceAccount carries the workload identity client ID is a CI check: a custom definition sees only the object under review) ✔    
Secrets only through External Secrets Operator (section 2.5): in application namespaces and <zone>-gateway, Secrets may be created or changed only by the zone’s ESO controller ServiceAccount (system:serviceaccount:<zone>-secrets:external-secrets-<zone>, checked on the admission request’s user) – Helm release Secrets, the AKS Flux extension’s agents and the KMS rewrite (aksService) excepted; the Secrets Store CSI add-on is disabled by the cluster IaC, inline csi volumes are refused by the storage rule and SecretProviderClass objects by their own rule ✔    
ESO objects in application namespaces and <zone>-gateway: SecretStore named key-vault, provider azurekv with the own zone vault or the platform vault, authType: WorkloadIdentity, serviceAccountRef: key-vault in the own namespace, controller: <zone>; ExternalSecret only against a SecretStore (not ClusterSecretStore), remoteRef.key / dataFrom names under the namespace’s <namespace>-- prefix (no find, no sourceRef); ClusterSecretStore, ClusterExternalSecret, PushSecret platform only ✔    
Requests set, probes set, no privileged / hostNetwork / hostPath for applications ✔    
Beyond the restricted pod security standard: readOnlyRootFilesystem: true (writable paths only as emptyDir), automountServiceAccountToken: false for application pods (they do not call the Kubernetes API; workload identity uses its own projected token) ✔    
VerticalPodAutoscaler: updateMode Off / Initial / InPlaceOrRecreate, controlledValues: RequestsOnly, maxAllowed set for every controlled resource (pod right-sizing); also checked by the merge gate against limits.yaml. Every Deployment has one, in InPlaceOrRecreate unless its pods carry platform/rightsizing: off (CI check on the rendered manifests) ✔ all three modes Off only
Application policies stay inside the cluster (app-egress): a CiliumNetworkPolicy or NetworkPolicy in an application namespace may allow egress only to toEndpoints and in-cluster toServices with a k8sService / k8sServiceSelector (the policy pairs of section 2.6); toCIDR, toCIDRSet, toEntities, toFQDNs and ipBlock are rejected, because the hub Firewall is the one decision out of the cluster (section 5.1); CiliumClusterwideNetworkPolicy and Cilium CIDR / egress gateway objects platform only; dnsPolicy: ClusterFirst ✔    
Cluster: Azure CNI powered by Cilium, encryption at host, customer-managed disk keys, private cluster, workload identity, the Azure Policy add-on, local accounts and SSH disabled (built-in definitions on the cluster resource, section 2.2); Azure Linux 3.0 on every node pool; ACNS and the Key Vault secrets provider add-on off ✔    
Replica and rollout guardrails (PDB existence: CI check) of section 3.4: replicas / HPA minReplicas, topologySpreadConstraints, RollingUpdate with maxUnavailable: 0, PodDisruptionBudgets with maxUnavailable ≥ 1 (never minAvailable, which can block every drain); replicas: 0 allowed for an application switched off; Jobs, CronJobs and queue consumers are exempt from the replica, spread, budget and rollout rules (non-serving workloads) ✔ ≥ 2 replicas, spread over nodes ≥ 3 replicas, zone spread
No persistent storage: StorageClass, PersistentVolume, PersistentVolumeClaim, inline csi and ephemeral volumes rejected (always deny, kube-system excepted)   ✔  
Reject Ingress; only HTTPS Gateway listeners; HTTPRoute only to the zone’s HTTPS listener and zone domain   ✔  
Every port of every Service in an application namespace declares appProtocol: kubernetes.io/h2c - the gateway speaks HTTP/2 to the pod, never HTTP/1.1 (h2c-upstream, section 4.1; a custom definition cannot see which Services an HTTPRoute references, so every port of the namespace’s Services is held to it); also a CI check on the rendered manifests   ✔  
The gateway’s TLS offers ALPN h2 only and TLS 1.3 only: Traefik’s default TLS options, a file-provider ConfigMap traefik-dynamic in every <zone>-gateway namespace, which the rule gateway-tls reads back - alpnProtocols exactly [h2], minVersion exactly VersionTLS13; the isolation tests prove the handshakes   ✔  
Reject LoadBalancer / NodePort Services outside <zone>-gateway   ✔  
Reject Ingress, Gateway, HTTPRoute; LoadBalancer only internal, in the zone’s ILB subnet, externalTrafficPolicy: Local; no NodePort     ✔
Namespace has platform/stateful-exception     ✔

OneK8s – a design under review; country codes and IP ranges are examples.Source of this pageComments and issues