Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Network Manager Admin Rule Terraform Module

A single security admin rule β€” the resource that is evaluated before network security groups, and that two of its three actions let it override entirely. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ›‘οΈ Writes one security admin rule into a network manager rule collection.
  • βš–οΈ Emits the action as four derived flags, because Allow, Deny and AlwaysAllow differ in more than severity.
  • 🎯 Reports how wide the match is β€” and flags the case where all five wildcards hold at once.
  • 🏷️ Reports service tags Microsoft documents as unsupported here, and destination ports on Microsoft's own high-risk list.
  • ⚠️ States four things it cannot see, including which networks the rule reaches and whether anything is deployed.

πŸ’‘ Why it matters: Microsoft documents security admin rules as always outranking network security group rules. Allow passes traffic on for NSG evaluation; Deny and AlwaysAllow end the evaluation there. So a rule written here can block traffic a team has explicitly permitted, or deliver traffic they have explicitly denied β€” and they cannot override back. Reading action = "AlwaysAllow" does not convey that unless you already know the model, which is why this module emits it.


❀️ Support this project

If this module saved you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
    NM["terraform-azurerm-network-manager"]
    CFG["a security admin configuration -- not authored as a module yet"]
    COLL["terraform-azurerm-network-manager-admin-rule-collection"]
    RULE["terraform-azurerm-network-manager-admin-rule"]
    GRP["network groups -- the collection names them, and they decide the blast radius"]
    NSG["network security groups on the targeted virtual networks"]
    DEP["a network manager DEPLOYMENT -- nothing takes effect until this exists"]

    NM -->|"owns the configuration"| CFG
    CFG -->|"id to security_admin_configuration_id"| COLL
    COLL -->|"id to admin_rule_collection_id"| RULE
    GRP -->|"ids to network_group_ids on the COLLECTION, not the rule"| COLL
    RULE -->|"EVALUATED FIRST -- Deny and AlwaysAllow never consult these"| NSG
    DEP -.->|"until the configuration is deployed, every rule here is INERT"| CFG

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class RULE self
    class COLL keystone
    class NM,CFG,GRP,NSG,DEP ext
Loading

Follow two edges. The network groups arrive at the collection, not here β€” which is why no output in this module reports a network count. And the edge from this rule to the network security groups is the feature: evaluated first, and for two of the three actions, never reaching them at all.


🧬 What this module builds

flowchart TB
    IN1["name and admin_rule_collection_id -- the only force-new fields"]
    IN2["action -- Allow continues to NSGs; Deny and AlwaysAllow END evaluation"]
    IN3["priority 1 to 4096, LOWER WINS -- collisions invisible from here"]
    IN4["protocol, direction, ports, sources, destinations"]
    RES["azurerm_network_manager_admin_rule.this -- a real ARM record, no locks"]
    OUT1["a real Resource ID plus the manager, configuration and collection names"]
    OUT2["terminates_evaluation_before_network_security_groups"]
    OUT3["matches_everything_in_its_direction, and the unsupported service tags"]
    OUT4["limits -- the collection decides the networks, siblings are invisible"]

    IN1 --> RES
    IN2 --> RES
    IN3 --> RES
    IN4 --> RES
    RES --> OUT1
    RES --> OUT2
    RES --> OUT3
    RES --> OUT4

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class RES self
    class IN1,IN2,IN3,IN4,OUT1,OUT2,OUT3,OUT4 ext
Loading
Resource Name Cardinality
azurerm_network_manager_admin_rule this single β€” a real ARM record under the collection
nested source / destination blocks β€” dynamic, unlimited

Only name and admin_rule_collection_id are force-new. The action updates in place.


βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, including authentication

Schema notes that bite

  • πŸ”΄ Two of the three actions terminate evaluation. Deny and AlwaysAllow stop traffic evaluation at this rule β€” the network security group is never consulted. Only Allow passes traffic on.
  • πŸ”΄ The action is NOT force-new. Flipping a rule from Allow to Deny is an in-place update whose effect is traffic being blocked across every virtual network the collection reaches.
  • πŸ”΄ The port range fields carry NO format validation. The provider checks only that each entry is non-empty, so "http" reaches Azure. This module adds a shape check.
  • πŸ”΄ The address prefix is NOT validated against its prefix type. A service tag labelled IPPrefix is accepted by the provider and rejected by Azure. This module refuses that mismatch.
  • πŸ”΄ Nothing is enforced until the configuration is DEPLOYED, and enforcement is eventually consistent after that.
  • 🟠 protocol is Any/Tcp/Udp/Icmp/Esp/Ah, case-sensitive β€” not the uppercase names network security group rules use, so a rule copied from an NSG fails on the casing alone.
  • 🟠 action is case-sensitive too, and Microsoft's prose writes AlwaysAllow as "Always allow" with a space while the API does not.
  • 🟠 priority is 1–4096, lower wins, and collisions within a collection are not detected by the provider or by this module.
  • 🟑 description is validated as NON-EMPTY when set β€” an empty string is refused, omission is fine.
  • 🟑 Three service tags are unsupported here β€” AzurePlatformDNS, AzurePlatformIMDS, AzurePlatformLKM β€” though they are valid tags elsewhere.
  • πŸ”΄ The delete is FORCED and cannot be disabled. The provider hard-codes the service's force flag. Microsoft defines it as "Delete the resource even if it is part of a deployed configuration. If the configuration has been deployed, the service will do a cleanup deployment in the background, prior to the delete." The Azure CLI defaults the same flag to false.
  • βœ… A real ARM record, not a composite. Zero locks; several rules write concurrently and safely.
  • βœ… An import guard exists, and all four timeouts β€” there is a real update function.

πŸ”‘ Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.Network/networkManagers/.../ruleCollections/rules/read the collection Refresh, and the create's import check.
Microsoft.Network/networkManagers/.../ruleCollections/rules/write the collection Create and update.
Microsoft.Network/networkManagers/.../ruleCollections/rules/delete the rule Destroy.

πŸ”’ This permission overrides other teams' network security groups, in both directions. A Deny written here blocks traffic a virtual network's owners have explicitly allowed, and they cannot restore it. An AlwaysAllow delivers traffic they have explicitly denied, and they cannot block it. Neither appears in their network security group configuration β€” the rule lives on a network manager they may not be able to read.

⚠️ The action is not force-new, so changing a rule's effect is an in-place update. A plan line reading "1 to change" can be traffic across every virtual network the collection reaches starting to be blocked.

ℹ️ The blast radius is not in this rule's configuration. It is the parent collection's network_group_ids, which someone else may widen without touching this rule at all.


Azure Prerequisites

  • The Microsoft.Network resource provider registered in the target subscription.
  • A network manager, a security admin configuration, and a rule collection β€” the whole parent chain.
  • Network groups on the collection, or the rule reaches nothing.
  • πŸ”΄ A deployment of the configuration to a region. Until then the rule is a definition.
  • An understanding of the sibling rules' priorities, which this module cannot see.

πŸ“ Module Structure

terraform-azurerm-network-manager-admin-rule/
β”œβ”€β”€ providers.tf    # required_version >= 1.12.0, azurerm ~> 4.0, no provider block
β”œβ”€β”€ variables.tf    # 12 inputs, 15 validations -- the parent, the action, the match
β”œβ”€β”€ main.tf         # one keystone `this`; dynamic source/destination blocks; the action flags
β”œβ”€β”€ outputs.tf      # 47 outputs -- id first, then the action semantics, then what it cannot see
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "deny_rdp" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-rdp-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Inbound"
  priority  = 100
  protocol  = "Tcp"

  destination_port_ranges = ["3389"]

  sources = [
    { address_prefix = "Internet", address_prefix_type = "ServiceTag" },
  ]
}

⚠️ That Deny cannot be overridden by any network security group it reaches. See example 2.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
name string the caller
admin_rule_collection_id string terraform-azurerm-network-manager-admin-rule-collection β†’ id
action string the caller β€” the security decision
direction / priority / protocol string / number / string the caller
source_port_ranges / destination_port_ranges list(string) the caller
sources / destinations list(object(...)) the caller
description string the caller
timeouts object(...) the caller β€” four keys

Emits

Output Description Consumed by
id A real ARM Resource ID reporting
terminates_evaluation_before_network_security_groups True for Deny and AlwaysAllow security review
matches_everything_in_its_direction All five wildcards at once pre-apply review
unsupported_service_tags_used Tags Microsoft documents as unsupported here pre-apply review

πŸ“š Example Library

1 Β· The smallest real call
module "deny_rdp" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-rdp-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Inbound"
  priority  = 100
  protocol  = "Tcp"
}

ℹ️ Six required arguments and no defaults among them. Every rule states its own action, direction, priority and protocol β€” there is nothing to inherit and nothing to get wrong by omission.

⚠️ With no sources, destinations or ports, this rule matches every TCP connection inbound to every network the collection reaches. matches_any_source, matches_any_destination and both port flags all return true. See example 5.

2 Β· The three actions are not three shades of one thing
# Passes matching traffic ON to network security group evaluation.
# The local team can still deny it.
action = "Allow"

# Blocks it. THE NETWORK SECURITY GROUP IS NEVER CONSULTED.
action = "Deny"

# Delivers it, bypassing any conflicting NSG rule.
# THE NETWORK SECURITY GROUP IS NEVER CONSULTED.
action = "AlwaysAllow"
check "no_central_rule_overrides_a_local_deny" {
  assert {
    condition     = !module.deny_rdp.overrides_a_denying_network_security_group
    error_message = "An AlwaysAllow rule delivers traffic that a network's own team has denied."
  }
}

πŸ”΄ Microsoft documents security admin rules as always having higher priority than network security group rules, and two of the three actions terminate evaluation there. Allow is the only value that leaves the local team's rules in play.

πŸ’‘ That is why this module emits four flags rather than the string: terminates_evaluation_before_network_security_groups, overrides_a_denying_network_security_group (AlwaysAllow), overrides_an_allowing_network_security_group (Deny), and leaves_network_security_groups_in_play (Allow). A reviewer reading action = "AlwaysAllow" without knowing the model would not see that a denying NSG has just been overruled.

⚠️ Note the casing: Microsoft's prose writes "Always allow" with a space; the API requires AlwaysAllow.

3 Β· Changing the action is an in-place update
module "rdp_rule" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "rdp-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny" # was "Allow" -- this is a MODIFICATION, not a replacement
  direction = "Inbound"
  priority  = 100
  protocol  = "Tcp"

  destination_port_ranges = ["3389"]
}

πŸ”΄ Only name and admin_rule_collection_id are force-new. Flipping the action shows in a plan as an ordinary in-place modification of one resource β€” while the effect is that traffic across every virtual network the collection reaches starts being blocked, or starts bypassing every network security group in its path.

⚠️ fields_that_can_change_after_creation lists action first for exactly that reason. A plan line reading "1 to change" on this resource deserves reading in full.

4 Β· Priority, and the collision nobody catches
module "deny_rdp" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-rdp-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Inbound"
  priority  = 100 # leave gaps: 100, 200, 300 -- not 1, 2, 3
  protocol  = "Tcp"
}

ℹ️ Lower is higher priority. Microsoft's own example: a deny rule at priority 10 overrides an allow rule at priority 20. The range is 1 to 4096.

πŸ”΄ Collisions are not detectable from here. Each instance of this module sees only its own priority and cannot read the collection's other rules, so two rules sharing a number produce no warning from Terraform and Azure decides the outcome. priority_collisions_are_not_detectable_by_this_module is a constant true saying so.

πŸ’‘ Leave gaps. A rule set numbered 100, 200, 300 can absorb an urgent insertion; renumbering a live rule set is exactly the change nobody wants to make under pressure.

5 Β· The rule that constrains nothing
module "deny_all_inbound" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-all-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Inbound"
  priority  = 4000
  protocol  = "Any" # and no sources, destinations or ports
}

check "no_unconstrained_rule" {
  assert {
    condition     = !module.deny_all_inbound.matches_everything_in_its_direction
    error_message = "This rule constrains nothing: any protocol, any address, any port."
  }
}

πŸ”΄ Five wildcards at once. Any protocol, any source, any destination, any source port, any destination port β€” a total block of one direction across every virtual network the collection reaches. As an AlwaysAllow it would instead be a hole no network security group can close.

πŸ’‘ Each wildcard is legal and ordinary alone, which is exactly why the combination gets its own flag rather than five separate readings. A backstop deny at a high priority number is a legitimate design; it is just one that should be deliberate.

6 Β· Ports, where the provider checks nothing
destination_port_ranges = ["3389"]        # a single port
destination_port_ranges = ["1000-2000"]   # a range
destination_port_ranges = ["22", "3389"]  # several
# REFUSED by this module -- the PROVIDER would accept it.
destination_port_ranges = ["http"]
destination_port_ranges = ["1000-"]
destination_port_ranges = ["80,443"]

πŸ”΄ The provider validates only that each entry is non-empty. No format check whatsoever, so "http" reaches Azure and fails there.

πŸ’‘ This module adds a shape check β€” a port or a port range β€” because that is something the caller either has right or has wrong, and getting it wrong on a rule that outranks network security groups is worth catching offline.

⚠️ Ports are deliberately NOT range-checked against 0–65535. Azure rather than the provider owns that limit, and this module does not invent limits it cannot source.

7 Β· The prefix-versus-type mismatch
# RIGHT
sources = [
  { address_prefix = "Internet",    address_prefix_type = "ServiceTag" },
  { address_prefix = "10.0.0.0/8",  address_prefix_type = "IPPrefix" },
]
# REFUSED by this module -- the PROVIDER would send it to Azure and fail there.
sources = [
  { address_prefix = "Internet", address_prefix_type = "IPPrefix" },
]

πŸ”΄ The provider does not check the prefix against the prefix type at all. It validates only that both strings are non-empty and that the type is one of the two, so a service tag labelled IPPrefix is accepted here and rejected at apply.

πŸ’‘ The check this module adds is deliberately minimal: an IPPrefix must contain at least one digit. An address or CIDR block always does; a tag such as Internet or VirtualNetwork never does. Prefixes are not parsed as CIDR, because the field legitimately accepts IPv4, IPv6 and bare addresses.

ℹ️ An empty sources list means any source β€” a deliberate wildcard, not an absence.

8 Β· Service tags, including three that do not work here
module "deny_platform_dns" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-platform-dns"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Outbound"
  priority  = 200
  protocol  = "Any"

  destinations = [
    { address_prefix = "AzurePlatformDNS", address_prefix_type = "ServiceTag" },
  ]
}

check "only_supported_service_tags" {
  assert {
    condition     = length(module.deny_platform_dns.unsupported_service_tags_used) == 0
    error_message = "A service tag was used that security admin rules do not support."
  }
}

⚠️ Microsoft documents three service tags as not supported by security admin rules β€” AzurePlatformDNS, AzurePlatformIMDS and AzurePlatformLKM β€” even though they are perfectly valid tags in a network security group rule.

πŸ’‘ Reported, never refused. The unsupported list is Microsoft's, it may change, and refusing a tag that later becomes supported would reject legal input. A non-zero list means the apply is likely to fail.

ℹ️ service_tags_used emits every tag, supported or not β€” worth reading because a service tag names a set of addresses Microsoft maintains and changes, so a rule written against one has a scope that moves without any Terraform run.

9 Β· The high-risk ports the feature exists for
module "deny_high_risk" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-high-risk-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Inbound"
  priority  = 100
  protocol  = "Tcp"

  destination_port_ranges = ["22", "3389", "1433"]

  sources = [
    { address_prefix = "Internet", address_prefix_type = "ServiceTag" },
  ]
}

output "confirming_intent" {
  value = module.deny_high_risk.high_risk_destination_ports_matched
}

πŸ’‘ This is emitted to confirm intent, not to object. Restricting exactly these ports centrally β€” so that a forgotten or misconfigured network security group cannot expose them β€” is the headline use case Microsoft documents for the feature.

⚠️ It is worth reading in the other direction too. A rule that AlwaysAllows port 3389 from the internet matches the same list, and is the precise opposite of the intended pattern. The flag reports the match; the action tells you which of the two you have.

ℹ️ The list is Microsoft's own: 20, 21, 22, 23, 25, 53, 80, 443, 1433 and 3389.

10 Β· Four things this module cannot see
output "honest_limits" {
  value = {
    reach       = module.deny_rdp.the_collection_decides_which_networks_this_rule_reaches
    siblings    = module.deny_rdp.the_module_cannot_see_the_other_rules_in_the_collection
    endpoints   = module.deny_rdp.security_admin_rules_do_not_apply_to_private_endpoints
    gateways    = module.deny_rdp.several_gateway_subnet_types_are_exempt_from_these_rules
    manager_only = module.deny_rdp.rules_apply_only_to_networks_the_manager_manages
  }
}

πŸ”΄ All five are constant true, and each is a gap between what a rule appears to cover and what it enforces. The collection owns the blast radius; sibling priorities are invisible; private endpoints in managed virtual networks are not covered at all; and several subnet types β€” Application Gateway, Bastion, Azure Firewall, Route Server, VPN Gateway, Virtual WAN and ExpressRoute gateway subnets β€” are exempt.

⚠️ The last one matters for a Deny written as a guardrail: it does not filter traffic in those subnets, which is correct behaviour for managed infrastructure and a surprise if you were counting on the rule covering everything.

11 Β· What NOT to do
# WRONG -- the CONFIGURATION where the COLLECTION belongs.
admin_rule_collection_id = var.security_admin_configuration_id

ℹ️ Refused. The two IDs differ only by the trailing /ruleCollections/<name> segments, which is what makes this the likeliest mistake β€” the error message says so.

# WRONG -- network security group casing.
protocol = "TCP"

ℹ️ Refused. It is Tcp. A rule copied from an NSG configuration fails on the casing alone.

# WRONG -- Microsoft's prose spelling.
action = "Always allow"

ℹ️ Refused. The API requires AlwaysAllow, with no space.

# WRONG -- an empty description is not the same as none.
description = ""

ℹ️ Refused by the provider. Omit the argument instead.

12 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-network-eastus"
  location = "eastus"

  tags = {
    environment = "production"
    workload    = "network-governance"
  }
}

module "network_manager" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager.git?ref=v1.0.0"

  name                = "nm-corp"
  resource_group_name = module.rg.name
  location            = module.rg.location

  scope = {
    subscription_ids = [var.managed_subscription_id]
  }

  scope_accesses = ["SecurityAdmin"]

  tags = module.rg.tags
}

# The configuration and the network group are not authored as modules in this
# library yet, so their Resource IDs are passed in.
module "high_risk_ports" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule-collection.git?ref=v1.0.0"

  name                            = "rc-high-risk-ports"
  security_admin_configuration_id = var.security_admin_configuration_id
  network_group_ids               = [var.production_network_group_id]

  description = "Baseline restrictions on high-risk inbound ports"
}

module "deny_rdp" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "deny-rdp-inbound"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "Deny"
  direction = "Inbound"
  priority  = 100
  protocol  = "Tcp"

  destination_port_ranges = ["3389"]

  sources = [
    { address_prefix = "Internet", address_prefix_type = "ServiceTag" },
  ]

  description = "Block inbound RDP from the internet across production"
}

# An AlwaysAllow that guarantees management access regardless of local rules.
module "always_allow_management" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-admin-rule.git?ref=v1.0.0"

  name                     = "always-allow-management"
  admin_rule_collection_id = module.high_risk_ports.id

  action    = "AlwaysAllow"
  direction = "Inbound"
  priority  = 50 # LOWER than the deny above, so it wins
  protocol  = "Tcp"

  destination_port_ranges = ["22", "3389"]

  sources = [
    { address_prefix = "10.10.0.0/24", address_prefix_type = "IPPrefix" },
  ]

  description = "Guarantee jump-host access to management ports"
}

output "governance" {
  value = {
    deny_terminates      = module.deny_rdp.terminates_evaluation_before_network_security_groups
    deny_high_risk_ports = module.deny_rdp.high_risk_destination_ports_matched
    allow_overrides_nsgs = module.always_allow_management.overrides_a_denying_network_security_group
    allow_priority       = module.always_allow_management.priority
    deny_priority        = module.deny_rdp.priority
    needs_deployment     = module.deny_rdp.this_rule_does_nothing_until_the_configuration_is_deployed
  }
}

πŸ”΄ The two rules together are the real lesson. The AlwaysAllow at priority 50 wins over the Deny at 100, so management access survives the guardrail β€” and it also bypasses every network security group on those networks, including one a team wrote deliberately to keep the jump host out.

⚠️ allow_overrides_nsgs is true. That is the flag a security review should be reading, and it is not visible from action = "AlwaysAllow" unless you already know the model.

πŸ’‘ needs_deployment is true as well: none of this is enforced until the configuration is deployed to a region, and no plan will tell you whether it has been.


πŸ“₯ Inputs

Required: name, admin_rule_collection_id, action, direction, priority, protocol Optional: source_port_ranges, destination_port_ranges, sources, destinations, description, timeouts

There is no tags variable β€” the resource exposes none. Tag the network manager.

Full input schemas
variable "name" {
  type = string
  # REQUIRED, FORCE-NEW. Non-empty is the provider's only check.
}

variable "admin_rule_collection_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored through /ruleCollections/<collection>.
  # THE COLLECTION decides which networks this rule reaches -- the rule names
  # none, and that list can widen without this rule changing.
}

variable "action" {
  type = string
  # REQUIRED. "Allow" | "Deny" | "AlwaysAllow", case-sensitive.
  # Deny and AlwaysAllow TERMINATE evaluation -- the NSG is never consulted.
  # NOT force-new: changing it is an in-place update.
}

variable "direction" {
  type = string
  # REQUIRED. "Inbound" | "Outbound", case-sensitive. One direction per rule.
}

variable "priority" {
  type = number
  # REQUIRED. 1 to 4096, LOWER WINS. Collisions within a collection are not
  # detectable by this module or by the provider.
}

variable "protocol" {
  type = string
  # REQUIRED. "Any"|"Tcp"|"Udp"|"Icmp"|"Esp"|"Ah", case-sensitive -- NOT the
  # uppercase names network security group rules use.
}

variable "source_port_ranges" {
  type    = list(string)
  default = []
  # The PROVIDER checks only non-emptiness. This module adds a shape check.
  # Empty means ANY port.
}

variable "destination_port_ranges" {
  type    = list(string)
  default = []
  # Usually the argument that matters -- high-risk destination ports are the
  # feature's headline use case.
}

variable "sources" {
  type = list(object({
    address_prefix      = string
    address_prefix_type = string # "IPPrefix" | "ServiceTag"
  }))
  default = []
  # The PROVIDER does not check the prefix against the type. This module
  # refuses a service tag labelled IPPrefix. Empty means ANY source.
}

variable "destinations" { }  # identical in shape and in what is unchecked

variable "description" {
  type    = string
  default = null
  # The provider refuses an EMPTY STRING while accepting omission.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Type Notes
id string A real ARM Resource ID
name string As configured; force-new
admin_rule_collection_id / admin_rule_collection_name string The parent
security_admin_configuration_name / network_manager_name string Parsed by counting back from the end
resource_group_name / subscription_id string Parsed
action string Read the four flags below instead
terminates_evaluation_before_network_security_groups bool True for Deny and AlwaysAllow
overrides_a_denying_network_security_group bool True for AlwaysAllow
overrides_an_allowing_network_security_group bool True for Deny
leaves_network_security_groups_in_play bool True for Allow β€” the only one
priority number Lower wins
priority_collisions_are_not_detectable_by_this_module bool Constant true
direction / protocol string As configured
matches_any_protocol / matches_any_source / matches_any_destination bool Wildcards
matches_any_source_port / matches_any_destination_port bool Wildcards
matches_everything_in_its_direction bool All five at once
source_count / destination_count number Zero means any
service_tags_used list(string) Their scope moves without a Terraform run
unsupported_service_tags_used list(string) Reported, never refused
high_risk_destination_ports_matched list(string) Confirms intent
the_collection_decides_which_networks_this_rule_reaches bool Constant true
the_module_cannot_see_the_other_rules_in_the_collection bool Constant true
security_admin_rules_do_not_apply_to_private_endpoints bool Constant true
several_gateway_subnet_types_are_exempt_from_these_rules bool Constant true
rules_apply_only_to_networks_the_manager_manages bool Constant true
this_rule_does_nothing_until_the_configuration_is_deployed bool Constant true
enforcement_is_eventually_consistent_not_immediate bool Constant true
changing_the_action_is_an_in_place_update bool Constant true
destroy_force_deletes_even_when_the_configuration_is_deployed bool Constant true β€” the mechanism
destroy_stops_this_rule_being_enforced_on_every_network_it_reached bool Constant true β€” the consequence
destroy_bypasses_the_documented_teardown_order bool Constant true
this_is_a_real_azure_resource_not_a_composite bool Constant true
the_provider_takes_no_lock_on_the_collection bool Constant true
an_import_guard_exists_on_this_resource bool Constant true
a_deleted_rule_disappears_from_state_without_an_error bool Constant true
no_secret_is_accepted_or_emitted_by_this_module bool Constant true
this_resource_supports_no_azure_resource_tags bool Constant true
description / has_description string / bool The empty-string asymmetry
force_new_fields list(string) Two β€” not the action
fields_that_can_change_after_creation list(string) action first
fields_azure_returns_on_read list(string) All of them

🧠 Architecture Notes

This is the resource that outranks network security groups. Microsoft documents security admin rules as always having higher priority than network security group rules and therefore evaluated first. Allow passes matching traffic on for network security group evaluation; Deny and AlwaysAllow end the evaluation there, so the network security group is never consulted at all. A rule written here can block traffic a team has explicitly permitted and deliver traffic they have explicitly denied, and they cannot override back.

So the action is emitted as four derived flags rather than one string. The three values differ in more than severity, and action = "AlwaysAllow" conveys nothing about overridden network security groups to a reviewer who does not already know the model. overrides_a_denying_network_security_group does, and it is the one worth a policy check: it is the single action that can widen access from a central configuration.

And the action is not force-new. Flipping a rule's effect is an in-place update β€” a plan line reading "1 to change" while traffic across every virtual network the collection reaches starts being blocked, or starts bypassing every network security group in its path.

Two checks are added beyond the provider, both for shapes the caller either has right or has wrong. Port entries must look like a port or a range, because the provider validates only non-emptiness and "http" otherwise reaches Azure. And an IPPrefix must contain a digit, because the provider does not check the prefix against its type and a service tag labelled IPPrefix fails at apply. Nothing else is invented: ports are not range-checked against 0–65535 because Azure owns that limit, and prefixes are not parsed as CIDR because the field legitimately accepts IPv4, IPv6 and bare addresses.

Two facts are reported rather than enforced, deliberately. The three unsupported service tags are Microsoft's list and it may change, so refusing one that later becomes supported would reject legal input. And the high-risk destination ports are reported to confirm intent β€” restricting them centrally is the feature's headline use case, so the flag is only alarming in combination with an AlwaysAllow.

Five limits are emitted as constants, because each is a gap between what a rule appears to cover and what it enforces. The collection owns the blast radius and can widen it without this rule changing; sibling rules and their priorities are invisible; private endpoints in managed virtual networks are not covered; several gateway subnet types are exempt; and rules reach only networks the manager manages. None is derivable from this resource's inputs, and each has surprised somebody.

Nothing is enforced until the configuration is deployed, and enforcement is eventually consistent afterwards β€” including for resources created in the targeted networks later.

The destroy is forced, and that is worth separating from its consequence. The provider hard-codes the service's force flag with no argument to disable it, and the Azure CLI defaults the same flag to false. So a terraform destroy does not fail on a rule belonging to a deployed configuration β€” it removes it, and Azure runs a background cleanup deployment first. Microsoft's teardown checklist prescribes undeploying before deleting any rule; the forced delete bypasses that. In the plan this is one resource destroyed; in the estate it is this rule ceasing to apply across every network the collection reaches, with the network security groups it was overriding taking effect again.

lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy here. On a rule that is somebody's guardrail, that is worth knowing.


🧱 Design Principles

Concern This module's position What the caller must type to change it
The action Four derived flags, because the string does not convey the model β€”
Port shape Checked β€” the provider checks only non-emptiness β€”
Prefix versus type Mismatch refused β€” the provider does not check it at all β€”
Port ranges 0–65535 Not invented β€” Azure owns that limit β€”
Unsupported service tags Reported, never refused β€” the list is Microsoft's and may change assert on the flag
High-risk ports Reported to confirm intent, not to object β€”
Five coverage gaps Emitted as constants β€” none is derivable β€”
The parent ID Anchored, with the likeliest mistake named in the message β€”
tags Absent by design β€” the resource exposes none tag the network manager

πŸ”’ The question here is not a setting on this resource. It is which action, at what priority, reaching which networks β€” and only the first two are visible from here.


πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module by tag β€” ?ref=v1.0.0 β€” never a branch.

Plan-only: this repository performs no cloud apply. A human applies from CI β€” and for this resource, after someone has read the action, the priority, and the collection's group list.


πŸ§ͺ Testing

What terraform validate covers, offline and without credentials:

  • A non-empty name, and the anchored collection ID check at both ends.
  • All three enums, case-sensitively β€” including AlwaysAllow versus "Always allow".
  • The priority range and whole-number rule.
  • The port shape, which the provider does not check.
  • The prefix-versus-type mismatch, which the provider does not check.
  • The description non-empty rule, mirroring the provider.

What only terraform plan exercises (credentials required):

  • That the collection exists, and the import guard if a rule of this name is in it.

What only terraform apply reveals:

  • Whether a service tag is one of the three unsupported here β€” though this module reports it first.
  • Whether a port value Azure rejects slipped past the shape check.

What nothing reveals at all:

  • The other rules in the collection and their priorities.
  • Which virtual networks the rule reaches.
  • Whether the configuration has been deployed, and therefore whether any of this is enforced.

ℹ️ terraform console fires root-module variable validations, unlike terraform validate on a calling configuration. It is the honest offline harness for the first list and for every derived flag β€” including one fixture per branch of all three action flags, both branches of each wildcard, and the matches_everything_in_its_direction combination broken by each of its five conditions in turn.


πŸ’¬ Example Output

Outputs:

id                                                  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-eastus/providers/Microsoft.Network/networkManagers/nm-corp/securityAdminConfigurations/sac-baseline/ruleCollections/rc-high-risk-ports/rules/deny-rdp-inbound"
admin_rule_collection_name                          = "rc-high-risk-ports"
security_admin_configuration_name                   = "sac-baseline"
network_manager_name                                = "nm-corp"
action                                              = "Deny"
terminates_evaluation_before_network_security_groups = true
overrides_an_allowing_network_security_group        = true
leaves_network_security_groups_in_play              = false
priority                                            = 100
matches_everything_in_its_direction                 = false
service_tags_used                                   = [
  "Internet",
]
unsupported_service_tags_used                       = []
high_risk_destination_ports_matched                 = [
  "3389",
]

ℹ️ Read the two true lines together. This rule blocks inbound RDP from the internet and a network security group permitting it has no effect β€” which is the point, and which the action string alone does not say.


πŸ” Troubleshooting

Symptom Cause Fix
must be a rule collection Resource ID The configuration was passed instead They differ only by the trailing segments
action must be exactly Lowercase, or "Always allow" with a space The API requires AlwaysAllow
direction must be exactly Lowercase Inbound or Outbound
protocol must be exactly one of NSG-style uppercase, e.g. TCP It is Tcp; a rule copied from an NSG fails here
whole number between 1 and 4096 A priority outside the range Lower wins; leave gaps
must be a port A port name, a half-range, or a comma list This module's own check; the provider has none
must contain at least one digit A service tag labelled IPPrefix Set the type to ServiceTag
must set address_prefix_type A value other than the two Case-sensitive
must not be empty or whitespace when set description = "" Omit the argument instead
Apply fails with a requires-import error A rule of this name is in the collection Import it, or choose another name
Apply fails on a service tag One of the three unsupported here unsupported_service_tags_used reports it first
The rule has no effect The configuration is not deployed Deploy it to the region
The rule took effect slowly Enforcement is eventually consistent Microsoft documents a short delay
A network security group is being ignored That is what Deny and AlwaysAllow do Use Allow to leave the local rules in play
Two rules conflict Priorities collided Not detectable here; check the collection
A private endpoint is not filtered Security admin rules do not apply to them A documented gap
A gateway subnet is not filtered Several subnet types are exempt Also documented
More networks affected than expected The collection's group list grew The blast radius is not in this rule
terraform destroy removed a live guardrail The delete is forced and cannot be disabled Undeploy the configuration first if that was not the intent

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."