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.
- π‘οΈ Writes one security admin rule into a network manager rule collection.
- βοΈ Emits the action as four derived flags, because
Allow,DenyandAlwaysAllowdiffer 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.
Allowpasses traffic on for NSG evaluation;DenyandAlwaysAllowend 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. Readingaction = "AlwaysAllow"does not convey that unless you already know the model, which is why this module emits it.
If this module saved you time:
- β Star the repository β it genuinely helps other people find it.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
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
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.
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
| 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.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module β the caller configures provider "azurerm" { features {} }, including authentication |
- π΄ Two of the three actions terminate evaluation.
DenyandAlwaysAllowstop traffic evaluation at this rule β the network security group is never consulted. OnlyAllowpasses traffic on. - π΄ The action is NOT force-new. Flipping a rule from
AllowtoDenyis 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
IPPrefixis 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.
- π
protocolisAny/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. - π
actionis case-sensitive too, and Microsoft's prose writesAlwaysAllowas "Always allow" with a space while the API does not. - π
priorityis 1β4096, lower wins, and collisions within a collection are not detected by the provider or by this module. - π‘
descriptionis 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.
| 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
Denywritten here blocks traffic a virtual network's owners have explicitly allowed, and they cannot restore it. AnAlwaysAllowdelivers 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.
- The
Microsoft.Networkresource 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.
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
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" },
]
}
β οΈ ThatDenycannot be overridden by any network security group it reaches. See example 2.
| 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 |
| 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 |
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_destinationand both port flags all returntrue. 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.
Allowis 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), andleaves_network_security_groups_in_play(Allow). A reviewer readingaction = "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 requiresAlwaysAllow.
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
nameandadmin_rule_collection_idare 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_creationlistsactionfirst 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_moduleis a constanttruesaying 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
AlwaysAllowit 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
IPPrefixis accepted here and rejected at apply.
π‘ The check this module adds is deliberately minimal: an
IPPrefixmust contain at least one digit. An address or CIDR block always does; a tag such asInternetorVirtualNetworknever does. Prefixes are not parsed as CIDR, because the field legitimately accepts IPv4, IPv6 and bare addresses.
βΉοΈ An empty
sourceslist 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_usedemits 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 thatAlwaysAllows 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 aDenywritten 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
AlwaysAllowat priority 50 wins over theDenyat 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_nsgsistrue. That is the flag a security review should be reading, and it is not visible fromaction = "AlwaysAllow"unless you already know the model.
π‘
needs_deploymentistrueas well: none of this is enforced until the configuration is deployed to a region, and no plan will tell you whether it has been.
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
}| 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 |
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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.
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
AlwaysAllowversus "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
descriptionnon-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 consolefires root-module variable validations, unliketerraform validateon 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 thematches_everything_in_its_directioncombination broken by each of its five conditions in turn.
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
truelines 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 theactionstring alone does not say.
| 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 |
azurerm_network_manager_admin_ruleβ provider reference- Security admin rules in Azure Virtual Network Manager β the evaluation model, the three actions, the unsupported service tags and the exempt subnets
- Azure network security groups overview β how admin rules and NSG rules interact
- Sibling modules:
terraform-azurerm-network-manager-admin-rule-collection,terraform-azurerm-network-manager,terraform-azurerm-network-security-group,terraform-azurerm-resource-group - This module's
SCOPE.mdβ the cross-module contract
π "Infrastructure as Code should be standardized, consistent, and secure."