Configures Deployment Safeguards on an AKS cluster — best-practice admission control via Azure Policy and Gatekeeper — targeting
hashicorp/azurerm ~> 4.0.
- ⚙️ Creates one
azurerm_kubernetes_cluster_deployment_safeguard, namedthis. A singleton per cluster. - 🎚️ Sets the enforcement
level—WarnorEnforce— as a closed, validated set. - 🔐 Defaults
pod_security_standards_leveltoBaseline, not the provider'sPrivileged. The provider's default is Kubernetes' unrestricted tier; this module makes you type it. - 🚪 Validates
excluded_namespacesagainst RFC 1123 and rejects the two documented mistakes — a comma-separated string and an embedded space. - 🧾 Emits the posture as booleans and an ordinal, so a
checkblock can assert on it instead of on a string. ⚠️ States the three things about this control that its name does not:Enforcemutates,Enforcefails open, and one exclusion bypasses two controls.
💡 Why it matters: Deployment Safeguards are the cheapest real improvement available to an AKS cluster — one resource, four arguments, and every workload is held to a published set of best practices. But it is a control whose behaviour diverges from its name in three documented ways, and none of them is visible in a plan. This module makes every computable rule a type error, flips the one permissive default it can, and emits the rest as facts you can read in a review rather than discover in an incident.
If this module saves you time:
- ⭐ Star the repository — it genuinely helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
AKS["terraform-azurerm-kubernetes-cluster"]
SG["terraform-azurerm-kubernetes-cluster-deployment-safeguard"]
EXT["terraform-azurerm-kubernetes-cluster-extension"]
TARB["terraform-azurerm-kubernetes-cluster-trusted-access-role-binding"]
POL["terraform-azurerm-policy-assignment -- the mechanism underneath, managed separately"]
RG -->|"resource_group_name"| AKS
AKS -->|"azure_policy_enabled must be true"| SG
AKS -->|"id as kubernetes_cluster_id"| SG
AKS -->|"id as cluster_id -- NOTE THE DIFFERENT ARGUMENT NAME"| EXT
AKS -->|"id as kubernetes_cluster_id"| TARB
EXT -->|"installs_into_namespace becomes an exclusion"| SG
SG -.->|"implemented with Azure Policy, but NOT managed through it"| POL
style SG fill:#0078D4,stroke:#004578,color:#ffffff
style AKS fill:#004578,stroke:#00335c,color:#ffffff
style RG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style EXT fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style TARB fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style POL fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Two edges matter more than the rest. The cluster must have azure_policy_enabled = true or safeguards are configured
and never take effect — Microsoft's first troubleshooting step. And the cluster-extension module feeds
installs_into_namespace in as an exclusion, because a platform component's namespace usually cannot meet workload
rules. The dashed edge is a warning: this is implemented with Azure Policy but must not be managed through it.
flowchart TB
CID["kubernetes_cluster_id -- REQUIRED, force-new"]
LVL["level -- REQUIRED, closed at Warn or Enforce. No default, so there is no empty call to make safe."]
PSS["pod_security_standards_level -- provider default is Privileged, the LEAST restrictive. THIS MODULE DEFAULTS TO Baseline."]
EXC["excluded_namespaces -- defaults to empty. Every entry bypasses BOTH controls."]
THIS["azurerm_kubernetes_cluster_deployment_safeguard.this"]
IDNOTE["its id IS the cluster id -- there is no separate safeguard id, and two declarations for one cluster collide silently"]
GK["Gatekeeper admission, via Azure Policy"]
DENY["denies noncompliant workloads"]
MUT["MUTATES admitted workloads -- sets CPU and memory requests, adds anti-affinity, strips a node-pool taint"]
OPEN["FAILS OPEN -- if Gatekeeper does not answer, the check is skipped and the deployment proceeds"]
DERIVED["derived: enforcement_is_blocking, pod_security_standards_rank, excluded_system_namespaces, posture_summary"]
CID --> THIS
LVL --> THIS
PSS --> THIS
EXC --> THIS
THIS --> IDNOTE
THIS -->|"configures"| GK
GK --> DENY
GK --> MUT
GK -.->|"by design, not a bug"| OPEN
THIS --> DERIVED
style THIS fill:#0078D4,stroke:#004578,color:#ffffff
style OPEN fill:#004578,stroke:#00335c,color:#ffffff
style CID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style LVL fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style PSS fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style EXC fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style IDNOTE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style GK fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style DENY fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style MUT fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
style DERIVED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
| Resource | Count | Notes |
|---|---|---|
azurerm_kubernetes_cluster_deployment_safeguard.this |
1 | The keystone. Singleton per cluster; the ARM name is fixed as default, which is why there is no name argument. |
nested timeouts block |
0 or 1 | The universal tail. All four operations supported (30m/5m/30m/30m). |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None here. The caller configures the provider, its authentication, and the mandatory features {} block. |
| Resource | azurerm_kubernetes_cluster_deployment_safeguard |
| ARM type | Microsoft.ContainerService/managedClusters (this is a property of the cluster) |
| API provider | Microsoft.ContainerService, version 2025-07-01 |
- 🔴 The resource's
idIS the cluster'sid. The provider states it plainly —idis "The ID of the Kubernetes Cluster (same askubernetes_cluster_id)" — and the import command confirms it, importing by cluster ID with no/deploymentSafeguards/defaultsuffix. - 🔴 So two declarations for one cluster hold the SAME Terraform ID and collide silently. Both plan, both apply, each overwrites the other, and the next plan shows an alternating diff that never settles. Terraform detects nothing. Declare this once per cluster.
- 🔴
EnforceMUTATES as well as denying. It sets default CPU and memory requests, adds pod anti-affinity rules and topology spread constraints, and removes aCriticalAddonsOnlytaint from a user node pool that does not set one. A workload can deploy successfully and not be the workload you wrote. - 🔴
Enforceis not a guarantee — Gatekeeper fails open by design. "There are no guarantees that Gatekeeper will respond to our networking call. Therefore, we make sure that the validation doesn't run in such cases so that the denial doesn't block your deployments." When the check cannot run, the deployment is allowed. - 🔴 One
excluded_namespacesentry bypasses BOTH the safeguards AND the Pod Security Standard. There is no way to exempt a namespace from one and keep the other. ⚠️ pod_security_standards_leveldefaults toPrivilegedin the provider — the least restrictive of three. This module defaults toBaseline.⚠️ All safeguard policies are bundled and cannot be individually toggled. "Deployment Safeguards is all or nothing."⚠️ On AKS Automatic clusters,levelcannot move fromEnforcetoWarn. The provider cannot see the cluster's mode, so no validation can catch this — it is an apply-time rejection.⚠️ Onlykubernetes_cluster_idis force-new. Everything else updates in place, which is what makesWarn→Enforcea cheap change.⚠️ The parent reference iskubernetes_cluster_idhere andcluster_idon the cluster-extension module for the same cluster. Do not infer it from a sibling.- ℹ️ A namespace name is a string this module cannot resolve. Excluding a namespace that does not exist is accepted and does nothing, with no error, ever.
Force-new facts come from the provider's documentation markdown. The binary schema carries no force-new marker at all. Shapes and types come from the schema; the operational rules come from Microsoft's platform documentation.
| Role | Scope | Why |
|---|---|---|
Azure Kubernetes Service Contributor |
the AKS cluster | Configure, update, delete. Sufficient on its own. |
Reader |
the AKS cluster | Refresh and plan. |
| (not required) Azure Policy rights | — | The notable row — see below. |
🔒 Enabling this needs NO Azure Policy permissions, and the interesting direction is the reverse. Microsoft: "you don't have to have the correct permissions to assign or delete Azure Policies. All that is required are permissions to the AKS Contributor role." So anyone with AKS Contributor on the cluster can move
EnforcetoWarnor add an exclusion, holding no policy right at all — a policy-based review of who can weaken admission control here will not find them. Put it in the cluster's access review.ℹ️ No credential is involved anywhere in this module. It accepts no secret, emits no secret, and reading it discloses nothing sensitive — so plan access and credential access do not come apart at all here. That is a positive property worth stating, because it does not hold across this library.
- The Azure Policy add-on enabled on the cluster —
azure_policy_enabled = true. Without it, safeguards are configured and never take effect. - Kubernetes 1.25 or later.
Microsoft.PolicyInsightsregistered on the subscription, for the compliance assessment. Note this is not the resource's own provider.- No lock on the cluster's resource group that blocks modification — a documented configuration-error cause, in direct tension with using a lock to protect this resource from destruction.
- The subscription not suspended, and the cluster not deleting.
- To read compliance in the portal: the Azure Policy initiative must be scoped to the cluster's resource group.
cluster_resource_groupis emitted for this.
terraform-azurerm-kubernetes-cluster-deployment-safeguard/
├── providers.tf # required_version + the pinned azurerm; no provider block
├── variables.tf # 5 inputs, 19 validations
├── main.tf # locals deriving the posture + the single keystone resource
├── outputs.tf # 24 outputs: id first, the derived posture, then ten constants
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore # the canonical library ignore file
provider "azurerm" {
features {}
}
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Warn"
}Two arguments. pod_security_standards_level defaults to Baseline — not the provider's Privileged.
⚠️ Start atWarnon an existing cluster. Read what it would have blocked and mutated, then move toEnforce. The change updates in place; only the cluster ID is force-new.
| Input | Type | Typical source |
|---|---|---|
kubernetes_cluster_id |
string |
module.aks.id |
level |
string, required |
caller |
pod_security_standards_level |
string |
caller; defaults to Baseline |
excluded_namespaces |
list(string) |
module.<extension>.installs_into_namespace |
timeouts |
object |
caller |
| Output | Consumed by |
|---|---|
id |
🔴 the cluster — see the note on its identity |
kubernetes_cluster_id |
compositions, where the cluster is what you mean |
enforcement_is_blocking, pod_security_standards_rank |
check blocks and compliance assertions |
excluded_namespaces, excluded_namespace_count |
security review |
posture_summary |
a composition's own output |
1 · The minimum call
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Warn"
}💡
posture_summaryreadsWarn / PSS Baseline / 0 namespace(s) exempt. Note theBaseline— the provider would have given youPrivileged.
2 · The recommended first step on an existing cluster
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Warn"
# Baseline is already the default; naming it makes the intent visible in review.
pod_security_standards_level = "Baseline"
}ℹ️ In
Warn, a noncompliant deployment still succeeds and prints the reason it would have been blocked. Collect those warnings, fix the manifests, then flip toEnforce.warns_without_blocking_anythingreadstrue— stated positively on purpose, because "not enforcing" is the kind of double negative a security review misreads.
3 · Enforcing
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Enforce"
}
⚠️ Enforcedoes two things, and the second surprises people. It denies noncompliant workloads —denies_noncompliant_workloads = true— and it mutates admitted ones:mutates_admitted_workloads = true. The mutations set default CPU and memory requests, add pod anti-affinity and topology spread constraints, and remove aCriticalAddonsOnlytaint from a user node pool. A workload that deploys may not be the workload you wrote.
4 · A hardened platform: Restricted
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Enforce"
pod_security_standards_level = "Restricted"
}🔒
is_the_most_restrictive_configurationreadstrue—Enforce,Restricted, no exclusions. This is the strongest posture the resource can express, and it is not recommended blindly:Restrictedforbids privilege escalation, restricts volume types to a published list, and requires a non-root pod spec. Workloads not written for it will break. Prove it inWarnfirst.
5 · Opting out of pod security, explicitly
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Enforce"
# The PROVIDER'S default. You have to type it here.
pod_security_standards_level = "Privileged"
}🔒
Privilegedis Kubernetes' entirely unrestricted tier — it applies essentially no pod-level constraint. This module makes it an explicit opt-out precisely so it appears in a diff.pod_security_standards_are_unrestrictedreadstrue, andpod_security_standards_diverge_from_provider_defaultreadsfalse, because you have gone back to what the provider would have done.
6 · Excluding a namespace, and what that really costs
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Enforce"
excluded_namespaces = ["flux-system"]
}
⚠️ One entry, two controls switched off. Microsoft documents that excluding a namespace exempts it from Deployment Safeguards and from Pod Security Standards. There is no way to keep one. So an exclusion added because a controller needs to set its own CPU request has also permitted privileged containers, host networking and host paths in that namespace.excluded_namespace_countis emitted so this is countable in a review.
7 · Wiring the exclusion from the extension that owns the namespace
module "flux_extension" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"
name = "flux"
cluster_id = module.aks.id
extension_type = "microsoft.flux"
namespaces = {
release_namespace = "flux-system"
}
}
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Enforce"
excluded_namespaces = [module.flux_extension.installs_into_namespace]
}💡 This is the right way to do it. The namespace is chosen in one place — the extension module — and read here. Hard-coding
"flux-system"in both modules is how two configurations drift apart while both look correct in isolation. Note also that the extension takescluster_idwhile this module takeskubernetes_cluster_id, for the same cluster: the provider is not consistent about that name across a cluster's children.
8 · The comma mistake Microsoft documents
# THIS FAILS AT PARSE TIME.
excluded_namespaces = ["ns1,ns2"]
# So does this -- the CLI takes a space-separated list, and the habit carries over.
excluded_namespaces = ["ns1 ns2"]
# THIS is correct.
excluded_namespaces = ["ns1", "ns2"]
⚠️ Microsoft names the first form explicitly as an error: "values likens1,ns2are not valid". Both rejections exist because both are published, computable mistakes rather than guesses. A namespace name is an RFC 1123 label and can contain neither a comma nor a space.
9 · Namespace names are validated against Kubernetes' own rule
# All of these FAIL AT PARSE TIME.
excluded_namespaces = ["Flux-System"] # upper case
excluded_namespaces = ["flux_system"] # underscore
excluded_namespaces = ["-flux"] # must start alphanumeric
excluded_namespaces = ["/subscriptions/00000000-0000-0000-0000-000000000000/..."]
excluded_namespaces = ["ns1", "ns1"] # duplicate
# Correct.
excluded_namespaces = ["flux-system"]ℹ️ These are RFC 1123 labels — Kubernetes' rule, not this module's invention: lower-case alphanumerics and
-, at most 63 characters, starting and ending alphanumeric. The Resource-ID rejection catches a different confusion entirely: a namespace is a cluster-internal object and has no Azure Resource ID. The duplicate is rejected because the list has no ordering or precedence semantics, so a repeat cannot mean anything a single entry does not.
10 · Rejecting the wrong `level` vocabulary
level = "Audit" # FAILS -- an Azure Policy EFFECT, not an enforcement level
level = "Deny" # FAILS -- same
level = "enforce" # FAILS -- recognised level, wrong case
level = "Enforce" # correct💡 The
Audit/Denyrejection is the interesting one. Deployment Safeguards are implemented with Azure Policy, so a caller who knows Azure Policy reaches for its vocabulary — a wrong value arriving from a different mechanism rather than from a typo. The error message names that mechanism and maps it across: Azure Policy'sAuditcorresponds toWarn, and itsDenyplusModifytogether correspond toEnforce.
11 · And the wrong Pod Security vocabulary
pod_security_standards_level = "baseline" # FAILS -- Kubernetes writes these lower case
pod_security_standards_level = "enforce" # FAILS -- that is a MODE, not a LEVEL
pod_security_standards_level = "Baseline" # correctℹ️ Kubernetes' pod-security labels pair a mode with a level —
pod-security.kubernetes.io/enforce: baseline. Only the level half belongs in this argument; the mode half is this resource's ownlevel. The error messages say exactly that, because a caller who typedenforcehere was not guessing — they were reading the Kubernetes documentation.
12 · Rejecting the plausible wrong cluster ID
# All FAIL AT PARSE TIME, each naming what you actually passed.
kubernetes_cluster_id = "/subscriptions/.../providers/Microsoft.Kubernetes/connectedClusters/arc-cluster"
# -> an Arc cluster. Deployment Safeguards are AKS-only; there is no Arc equivalent.
kubernetes_cluster_id = "/subscriptions/.../providers/Microsoft.ContainerService/fleets/fleet-prod"
# -> a fleet. SHARES the Microsoft.ContainerService prefix, which is why it transposes so easily.
kubernetes_cluster_id = "/subscriptions/.../managedClusters/aks-east/agentPools/system"
# -> a node pool. Safeguards are cluster-wide and cannot be scoped to one pool.🔒 The regex is anchored at both ends, so neither a truncated ID nor a child's ID satisfies it. Each message names the resource you actually supplied — "invalid ID" tells someone who believes they passed a cluster nothing.
13 · Asserting the posture in a check block
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
level = "Enforce"
}
check "production_admission_control" {
assert {
condition = module.safeguards.enforcement_is_blocking
error_message = "Production clusters must run Deployment Safeguards in Enforce, not Warn."
}
assert {
condition = module.safeguards.pod_security_standards_rank >= 1
error_message = "Pod Security Standards must be Baseline or Restricted, never Privileged."
}
assert {
condition = !module.safeguards.excludes_a_system_namespace
error_message = "A system namespace is exempt from admission control. Review why."
}
}💡 This is what the boolean and ordinal outputs are for.
pod_security_standards_rank(0Privileged,1Baseline,2Restricted) makes "at least Baseline" expressible; the string does not. Andexcludes_a_system_namespaceis reported rather than rejected by the module — a platform component sometimes must be exempt — so the composition is the right place to decide whether it is acceptable here.
14 · Several clusters, one posture
module "cluster_safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
for_each = module.regional_clusters
kubernetes_cluster_id = each.value.id
level = "Enforce"
pod_security_standards_level = "Baseline"
}
output "posture_by_cluster" {
value = { for k, m in module.cluster_safeguards : k => m.posture_summary }
}
⚠️ for_each, nevercount— and note the harder constraint this resource imposes: exactly one declaration per cluster. Because the resource's ID is the cluster's ID, two declarations for the same cluster share a Terraform ID, both apply, and each overwrites the other. Terraform reports no conflict; you get an alternating diff that never settles. Keying by cluster is what keeps that from happening.
15 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
locals {
platform_tags = {
environment = "production"
owner = "platform-engineering"
}
}
module "platform_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-platform-eastus"
location = "eastus"
tags = local.platform_tags
}
module "aks" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster.git?ref=v1.0.0"
name = "aks-platform-eastus"
resource_group_name = module.platform_rg.name
location = module.platform_rg.location
dns_prefix = "aks-platform"
# MANDATORY for Deployment Safeguards. Without it they configure and never take effect.
azure_policy_enabled = true
# Deployment Safeguards require Kubernetes 1.25 or later.
kubernetes_version = "1.30"
default_node_pool = {
name = "system"
node_count = 3
vm_size = "Standard_D4s_v5"
}
identity = {
type = "SystemAssigned"
}
tags = local.platform_tags
}
# A platform extension whose namespace cannot meet workload rules.
module "flux_extension" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-extension.git?ref=v1.0.0"
name = "flux"
cluster_id = module.aks.id
extension_type = "microsoft.flux"
namespaces = {
release_namespace = "flux-system"
}
}
module "safeguards" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kubernetes-cluster-deployment-safeguard.git?ref=v1.0.0"
kubernetes_cluster_id = module.aks.id
# Start at Warn, read the warnings, then change this one string to Enforce.
# It updates in place -- only the cluster ID is force-new.
level = "Enforce"
# Baseline is this module's default; named here so the posture is explicit in review.
pod_security_standards_level = "Baseline"
# Read from the module that OWNS the namespace, not hard-coded in two places.
excluded_namespaces = [
module.flux_extension.installs_into_namespace,
]
}
check "admission_control_is_real" {
assert {
condition = module.safeguards.enforcement_is_blocking
error_message = "Deployment Safeguards must be in Enforce on this cluster."
}
assert {
condition = !module.safeguards.pod_security_standards_are_unrestricted
error_message = "Pod Security Standards must not be Privileged."
}
assert {
condition = module.safeguards.excluded_namespace_count <= 1
error_message = "At most one namespace may be exempt: the GitOps controller's own."
}
}
output "cluster_admission_posture" {
value = {
cluster = module.safeguards.cluster_name
summary = module.safeguards.posture_summary
blocking = module.safeguards.enforcement_is_blocking
mutating = module.safeguards.mutates_admitted_workloads
pss_rank = module.safeguards.pod_security_standards_rank
exempt = module.safeguards.excluded_namespaces
guaranteed = false # see enforce_is_not_a_guarantee_because_gatekeeper_fails_open
}
}💡 Three things this composition gets right that are easy to get wrong.
azure_policy_enabled = trueon the cluster, without which the safeguards silently do nothing. The exclusion read from the extension module that chose the namespace, rather than typed twice. And thecheckblock asserting on booleans, so the posture is verified rather than assumed.
⚠️ Noteguaranteed = falsein the output, and that it is hard-coded.Enforceis the intent; it is not a guarantee, because Gatekeeper fails open. A compliance claim resting on this resource alone is overstated, and the honest thing is to say so in the output rather than letblocking = truebe read as a boundary.
Required (2): kubernetes_cluster_id (force-new), level.
Optional (3): pod_security_standards_level (defaults to Baseline, not the provider's Privileged),
excluded_namespaces, timeouts.
There is deliberately no name, tags, location or resource_group_name — the resource has no such arguments.
The name is absent because the ARM object is a singleton called default.
Full input schemas
variable "kubernetes_cluster_id" {
type = string
# REQUIRED, force-new. Anchored at both ends to
# .../Microsoft.ContainerService/managedClusters/<name>.
# Rejects by name: connectedClusters (Arc), fleets, /agentPools/ children.
# NOTE: this argument is `cluster_id` on the cluster-extension module.
}
variable "level" {
type = string
# REQUIRED. CLOSED set: "Warn" | "Enforce".
# Also rejects an Azure Policy effect (Audit, Deny, Modify, ...) by name,
# and a recognised level in the wrong case.
}
variable "pod_security_standards_level" {
type = string
default = "Baseline"
# CLOSED set: "Baseline" | "Privileged" | "Restricted".
# THE PROVIDER DEFAULTS THIS TO "Privileged", THE LEAST RESTRICTIVE.
# This module defaults to "Baseline" instead; "Privileged" is an explicit opt-out.
# Also rejects a Pod Security Admission MODE (enforce/warn/audit) where a LEVEL belongs.
}
variable "excluded_namespaces" {
type = list(string)
default = []
# EVERY ENTRY BYPASSES BOTH THE SAFEGUARDS AND THE POD SECURITY STANDARD.
# Each must be an RFC 1123 label: lower-case alphanumerics and "-", <= 63 chars,
# starting and ending alphanumeric. Rejects a comma, a space, a Resource ID,
# an empty entry, and duplicates.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
# Provider defaults: create 30m, read 5m, update 30m, delete 30m.
}
⚠️ Object-type conversion silently discards extra keys. A misspelled key insidetimeouts—updatedforupdate— is dropped with no error at all. It is the one input mistake here that produces no signal, so read the plan rather than trusting the absence of an error.
| Output | Description |
|---|---|
id |
🔴 The CLUSTER's ID. The provider defines it as "the ID of the Kubernetes Cluster (same as kubernetes_cluster_id)". Emitted first per convention; prefer kubernetes_cluster_id when you mean the cluster. |
kubernetes_cluster_id |
The cluster. Identical in value to id. |
cluster_name / cluster_resource_group / cluster_subscription_id |
Parsed from the Resource ID. |
level |
Warn or Enforce, as held. |
enforcement_is_blocking |
Derived — the single most consequential fact, as a boolean a review can grep for. |
denies_noncompliant_workloads |
true under Enforce: noncompliant workloads are rejected. |
mutates_admitted_workloads |
true under Enforce: admitted workloads may be changed on the way in. |
warns_without_blocking_anything |
true under Warn. Stated positively so no one reasons through a negation. |
pod_security_standards_level |
The applied level. |
pod_security_standards_rank |
0 Privileged, 1 Baseline, 2 Restricted. Higher is more restrictive. |
pod_security_standards_are_unrestricted |
true only when someone typed Privileged. |
pod_security_standards_diverge_from_provider_default |
true on an empty call, because this module's default is Baseline. |
excluded_namespaces |
The exemptions, in full. Each is an exposure hole. |
excluded_namespace_count / has_excluded_namespaces |
The count and its boolean. |
excluded_system_namespaces / excludes_a_system_namespace |
System namespaces among the exemptions. Reported, never rejected. |
excludes_the_default_namespace |
Called out separately: default is where anything applied without a namespace lands. |
posture_summary |
The configuration as one reviewable line. |
is_the_most_restrictive_configuration |
Enforce + Restricted + no exclusions. |
enforce_is_not_a_guarantee_because_gatekeeper_fails_open |
Constant. A failure-timing table with two Never rows. |
all_safeguard_policies_are_bundled_and_cannot_be_individually_toggled |
Constant. |
excluded_namespaces_bypass_both_safeguards_and_pod_security_standards |
Constant. |
cannot_switch_from_enforce_to_warn_on_an_aks_automatic_cluster |
Constant. Unverifiable from here. |
two_declarations_for_one_cluster_collide_silently |
Constant. |
destroying_this_resource_disables_enforcement_on_a_live_cluster |
Constant. |
enabling_this_does_not_require_azure_policy_permissions |
Constant. Cuts against the usual assumption. |
this_resource_has_no_name_tags_location_or_resource_group_name |
Constant. Why the tags tail is absent. |
the_arm_type_lives_under_containerservice_unlike_a_cluster_extension |
The ARM type, and the contrast with a sibling. |
secure_by_default_cannot_apply_to_a_required_enum |
Constant. Where the rule applies, where it cannot, and the compensations. |
No secret is accepted or emitted anywhere in this module.
The identity of this resource is the thing to understand first. Its Terraform id is the cluster's Resource ID —
the provider says so, and the import command proves it. That is not a cosmetic detail. It means a downstream module
handed id receives the cluster (and if it wanted the cluster, the mistake is masked); it means importing is done with
a cluster ID; and it means two declarations targeting one cluster hold the same Terraform ID. Terraform has no
uniqueness constraint to violate here, so both plan, both apply, each overwrites the other, and the next plan shows an
alternating diff that never converges. The ARM object is a singleton named default, which is also why there is no
name argument. Declare this once per cluster and let anything else consume its outputs.
Enforce is two behaviours, and the module emits them separately on purpose. Denial is what everyone pictures.
Mutation is the other half: Enforce sets default CPU and memory requests, adds pod anti-affinity rules and topology
spread constraints, and strips a CriticalAddonsOnly taint from a user node pool that does not declare one. So a
manifest can be applied, reported successful, and be running with a spec nobody wrote. denies_noncompliant_workloads
and mutates_admitted_workloads derive from the same input and are still separate outputs, because they are coupled
only by the current behaviour of one enum value — and because one combined flag invites a reader to assume denial and
stop there.
Enforce is also not a guarantee, and no configuration can make it one. Gatekeeper performs the admission check
and operates fail-open by design: when it cannot be reached, the validation is skipped so that a denial does not block
deployments. Availability is preferred over enforcement. That is a defensible choice for a best-practices control and
the opposite of what the word "Enforce" implies, so it is emitted as a constant with a failure-timing table whose
decisive rows read Never — because a skipped check produces no error, no warning, and no record. This resource
raises the floor; it does not establish a boundary. A compliance claim resting on it alone is overstated.
The one permissive default that could be flipped, was. pod_security_standards_level defaults to Privileged in
the provider — Kubernetes' unrestricted tier, applying essentially no pod-level constraint. This suite's rule is that an
enum containing a permissive member defaults to the locked-down member, so this module defaults to Baseline:
restrictive enough to block known privilege escalations, permissive enough not to break ordinary workloads, and the same
choice Microsoft makes for its own AKS Automatic clusters. Restricted was not chosen as the default because it
genuinely breaks workloads not written for it. The divergence from the provider is emitted rather than hidden, so a
reader comparing plans is not left with an unexplained difference.
level is required, so secure-by-default has nothing to act on — and that is stated rather than glossed. There is
no empty call to make safe. The three compensations this suite prescribes are all present: the value set is enforced as
closed (the provider enumerates it exhaustively, so this is not an invented constraint); the restrictive value is named
inside the error message, alongside a check that catches Azure Policy's own Audit/Deny/Modify vocabulary — a wrong
value arriving from a different mechanism rather than a typo, which is exactly the case where naming the other
mechanism in the message is worth the effort; and the choice is emitted as four derived flags.
Nothing is grouped into a structured variable here, and the absence is deliberate. The technique this suite uses for
a rule spanning two fields needs both fields in one variable, because a validation {} may only reference its own. The
only cross-field rule Microsoft publishes for this resource involves the cluster's mode — an AKS Automatic cluster
cannot move from Enforce to Warn — and the cluster's mode is neither an input nor derivable from the Resource ID.
No validation can reach it, so it is emitted as a constant and rejected by Azure at apply. Folding a field in to
manufacture the check would misrepresent the schema.
Exclusions are the only dial, and they are blunt. All safeguard policies are bundled: "Deployment Safeguards is all or nothing." When one policy breaks a workload, the sole remedy is to exempt its namespace — which switches off every safeguard and the Pod Security Standard for that namespace. That asymmetry between the size of the problem and the size of the available fix is why the exclusion list is emitted in full rather than summarised.
Report-versus-reject was decided per check. A comma or an embedded space in a namespace entry is rejected, because Microsoft documents both as errors and both are computable. A system namespace in the exclusion list is reported, because a caller could plausibly have meant it — a platform component's namespace sometimes must be exempt. A duplicate is rejected, because the list carries no ordering or precedence semantics, so a repeat cannot express anything a single entry does not.
What this module cannot see, and says so. Whether the Azure Policy add-on is on. Whether the cluster runs 1.25 or later. Whether the cluster is Automatic or Standard. Whether a named namespace exists — excluding a nonexistent namespace is accepted and does nothing, silently. Each of those is an apply-time or never-time failure, and each is named in the outputs or the troubleshooting table rather than approximated by a validation that could reject legal input.
| Concern | This module's default | Opt-out (you must type it) |
|---|---|---|
| Pod Security Standards | Baseline — diverging from the provider's Privileged |
pod_security_standards_level = "Privileged" |
| Namespace exemptions | none — the empty list | add entries, each bypassing two controls |
| Enforcement level | no default; required | — the caller must choose, and the error message names Enforce as the restrictive value |
| Secrets | none accepted, none emitted | no opt-out; the resource involves no credential at all |
| Policy selection | all safeguards active | no opt-out — the bundle cannot be narrowed |
🔒 Where secure-by-default applies and where it cannot.
pod_security_standards_levelis the case where it applies, and the default is flipped from the provider's permissivePrivilegedtoBaseline.levelis the case where it cannot: the provider makes it required, so there is no empty call, and the compensations are a closed value set, the restrictive value named in the error message, and the posture emitted as derived flags. Seesecure_by_default_cannot_apply_to_a_required_enum.
⚠️ And the honest limit no default could fix:Enforcefails open. Seeenforce_is_not_a_guarantee_because_gatekeeper_fails_open.
terraform init -backend=false
terraform validate
terraform fmt -checkPin by tag — ?ref=v1.0.0 — never a branch. This library is plan-only: a human applies from CI.
The operational sequence that matters here:
# 1. Configure at Warn. Nothing is blocked.
# 2. Deploy as usual and collect what would have been denied or mutated.
# 3. Fix the manifests.
# 4. Change level to "Enforce". It updates IN PLACE -- no replacement.
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[].change.actions' # expect ["update"]
⚠️ Destroying this resource silently disables admission control on a live cluster. It reads as one destroyed resource.lifecycleis not valid inside amoduleblock, soprevent_destroyis not available to a caller; aCanNotDeletemanagement lock on the cluster is — bearing in mind that a lock on the resource group can itself block configuring safeguards.
The offline gate is init -backend=false → validate → fmt -check.
What only a root-module terraform console proves: that each validation {} block fires. terraform validate on a
calling configuration does not run root-variable validations. Feed it deliberately bad .tfvars and read the line
numbers, not the message text — Terraform hard-wraps messages, so grepping for a phrase silently matches nothing.
echo 'var.level' | terraform console -var-file=bad.tfvars
⚠️ Terraform skips a validation whose referenced variable has already failed, and suppression is per-variable, so a short error list is not proof a check is missing. Note in particular thatalltrue([for ...])over an empty collection istrue— everyexcluded_namespacescheck needs a fixture with a non-empty list, or it reports success while testing nothing.
What only a real plan/apply against Azure exercises: whether the Azure Policy add-on is enabled, whether the
cluster runs Kubernetes 1.25 or later, whether the cluster is Automatic (and therefore cannot accept Warn), whether
Microsoft.PolicyInsights is registered, and whether a named namespace exists. None of that is visible offline, and
none of it is approximated by a validation here.
id = "/subscriptions/.../resourceGroups/rg-platform-eastus/providers/Microsoft.ContainerService/managedClusters/aks-platform-eastus"
kubernetes_cluster_id = "/subscriptions/.../resourceGroups/rg-platform-eastus/providers/Microsoft.ContainerService/managedClusters/aks-platform-eastus"
cluster_name = "aks-platform-eastus"
cluster_resource_group = "rg-platform-eastus"
level = "Enforce"
enforcement_is_blocking = true
denies_noncompliant_workloads = true
mutates_admitted_workloads = true
warns_without_blocking_anything = false
pod_security_standards_level = "Baseline"
pod_security_standards_rank = 1
pod_security_standards_are_unrestricted = false
pod_security_standards_diverge_from_provider_default = true
excluded_namespaces = ["flux-system"]
excluded_namespace_count = 1
excluded_system_namespaces = []
excludes_the_default_namespace = false
posture_summary = "Enforce / PSS Baseline / 1 namespace(s) exempt"
is_the_most_restrictive_configuration = false
ℹ️ Note
idandkubernetes_cluster_idare the same string. That is correct and is the provider's design, not a module defect.ℹ️
is_the_most_restrictive_configuration = falsehere despiteEnforce— because the Pod Security Standard isBaselinerather thanRestrictedand one namespace is exempt. Both are reasonable choices; the output exists so a "maximum posture" claim can be checked rather than asserted.
| Symptom | Cause | Fix |
|---|---|---|
| Safeguards configured but nothing is warned or blocked | The Azure Policy add-on is not enabled on the cluster. Microsoft's own first troubleshooting step. | Set azure_policy_enabled = true on the cluster module. |
| Apply fails: unsupported Kubernetes version | The cluster runs earlier than 1.25. | Upgrade the cluster, then configure safeguards. |
Apply rejects level = "Warn" |
The cluster is AKS Automatic, which cannot leave Enforce. The provider cannot see the cluster's mode, so this is not catchable at plan. |
Keep Enforce on Automatic clusters; use excluded_namespaces as the only available relief. |
| Apply fails: resource group locked | A lock on the cluster's resource group blocks the modification. | Remove the lock, apply, reinstate it — and note the tension with using a lock to protect this resource. |
level rejected, message naming Azure Policy |
Audit, Deny or Modify was passed. Deployment Safeguards are built on Azure Policy, so the vocabulary carries over. |
Audit → Warn; Deny plus Modify → Enforce. |
level rejected for case |
enforce instead of Enforce. Kubernetes writes its modes lower case. |
Use exact title case. |
pod_security_standards_level rejected, message naming a MODE |
enforce/warn/audit was passed — those are Pod Security Admission modes, not levels. |
Pass Baseline, Privileged or Restricted. The mode half is this resource's level. |
excluded_namespaces rejected for a comma |
["ns1,ns2"] — the mistake Microsoft documents by name. |
["ns1", "ns2"]. |
excluded_namespaces rejected for a space |
["ns1 ns2"] — the CLI takes a space-separated list and the habit carries over. |
Split into separate list elements. |
excluded_namespaces rejected as not a valid name |
Upper case, an underscore, a dot, or a leading -. These are Kubernetes' RFC 1123 rules, not this module's. |
Lower-case alphanumerics and -, starting and ending alphanumeric, ≤ 63 characters. |
excluded_namespaces rejected for a leading / |
A Resource ID was passed where a namespace name belongs. Namespaces have no Azure Resource ID. | Pass the bare name, e.g. flux-system. |
excluded_namespaces rejected for a duplicate |
The list has no ordering or precedence semantics, so a repeat cannot mean anything extra. | Remove the duplicate. |
Cluster ID rejected, naming connectedClusters |
An Arc cluster. Deployment Safeguards are AKS-only. | Pass a managedClusters ID. There is no Arc equivalent of this resource. |
Cluster ID rejected, naming fleets |
A fleet ID — it shares the Microsoft.ContainerService prefix, which is why the two transpose. |
Pass a cluster ID. Each member cluster needs its own safeguard configuration. |
Cluster ID rejected, naming agentPools |
A node pool's ID. | Truncate at the cluster: module.aks.id. Safeguards are cluster-wide. |
| A workload deployed successfully but is running a spec you did not write | Enforce mutates. It sets CPU and memory requests, adds anti-affinity and topology spread constraints, and strips a CriticalAddonsOnly taint. |
Expected behaviour. Inspect the running spec, and write the manifest to match if you want the diff to be empty. |
A noncompliant workload was admitted under Enforce |
Gatekeeper failed open. When it cannot be reached the validation is skipped so the denial does not block deployments. | Nothing to fix — this is the documented design. Do not treat Enforce as a hard boundary. |
| A namespace's workloads are unchecked and you cannot see why | The namespace is in excluded_namespaces, which exempts it from safeguards and Pod Security Standards. |
Read excluded_namespaces. There is no per-control exemption. |
| Excluding a namespace had no effect at all | The namespace does not exist. Azure accepts a name it cannot resolve and does nothing. No error, ever. | Check the spelling against the cluster. |
| The plan alternates on every run and never settles | Two declarations for the same cluster. They share one Terraform ID and overwrite each other. | Declare this resource once per cluster; have other modules consume its outputs. |
| One safeguard policy breaks a workload and you want only that one off | All policies are bundled and cannot be individually toggled. | Fix the workload, or exempt its namespace — accepting that this switches off every safeguard and the Pod Security Standard there. |
| Enforcement disappeared and no policy change was logged | Someone with AKS Contributor changed level or added an exclusion. No Azure Policy right is needed to do this. |
Review cluster RBAC, not policy RBAC. See enabling_this_does_not_require_azure_policy_permissions. |
| The compliance dashboard shows no data | The Azure Policy initiative is not scoped to the cluster's resource group. | Scope it there. cluster_resource_group is emitted for this. |
azurerm_kubernetes_cluster_deployment_safeguard— the provider resource.- Use Deployment Safeguards in AKS — the policy list, the mutations, the AKS Automatic restriction, and the fail-open note.
- Troubleshooting Deployment Safeguards — the documented error cases, including the
ns1,ns2rejection. - Kubernetes Pod Security Standards — what
BaselineandRestrictedactually require. SCOPE.md— this module's cross-module contract.- Siblings:
terraform-azurerm-kubernetes-cluster(the parent),terraform-azurerm-kubernetes-cluster-extension,terraform-azurerm-kubernetes-cluster-trusted-access-role-binding,terraform-azurerm-policy-assignment.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

