The unit a security admin configuration targets β created here, and populated somewhere else entirely. Targets
hashicorp/azurerm ~> 4.0.
- π― Creates the network group that rule collections name in
network_group_ids. - π΄ States, four ways, that this resource does not carry membership β the group is created empty.
β οΈ Names the silent failure: a rule set targeting an empty group is enforced on nothing, with no error.- π·οΈ Emits the effective member type, and reports that
SubnetneedsRoutingon the manager. - π΄ Names the forced destroy: deleting this group deletes no rule, and yet every rule stops reaching these networks.
π‘ Why it matters: there is no
membersargument in the schema. A successful apply proves the group exists and proves nothing about what is in it β and where membership is policy-driven, it changes with no Terraform run at all. This module reports what it cannot see rather than letting an empty group look like coverage.
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["azurerm_network_manager<br/>Routing scope access needed for a Subnet group"]
GRP["azurerm_network_manager_network_group<br/>THIS MODULE"]
MEMBERS["static members, or an Azure Policy rule<br/>NOT THIS RESOURCE"]
CFG["azurerm_network_manager_security_admin_configuration"]
COLL["azurerm_network_manager_admin_rule_collection<br/>names this group in network_group_ids"]
RULE["azurerm_network_manager_admin_rule"]
VNET["member virtual networks"]
NSG["azurerm_network_security_group"]
NM -->|"id to network_manager_id"| GRP
NM -->|"id to network_manager_id"| CFG
MEMBERS -.->|"populate the group outside Terraform"| GRP
GRP -->|"id to network_group_ids on the COLLECTION"| COLL
CFG -->|"id to security_admin_configuration_id"| COLL
COLL -->|"id to admin_rule_collection_id"| RULE
GRP -->|"decides which networks are reached"| VNET
RULE -.->|"evaluated BEFORE and outranks"| NSG
NSG --> VNET
style GRP fill:#0078D4,stroke:#004578,color:#ffffff
style NM fill:#004578,stroke:#004578,color:#ffffff
style MEMBERS fill:#B71C1C,stroke:#7F0000,color:#ffffff
style CFG fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style COLL fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style RULE fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style VNET fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style NSG fill:#F3F2F1,stroke:#8A8886,color:#201F1E
The red node is the one to notice: membership arrives from static members or an Azure Policy rule, along a dotted edge, because none of it passes through this resource. Everything below the group β the collection, the rules, the override of every network security group they reach β depends on a membership this module cannot see.
flowchart TB
IN1["name<br/>REQUIRED, force-new"]
IN2["network_manager_id<br/>REQUIRED, force-new"]
IN3["member_type<br/>VirtualNetwork by default, Subnet needs Routing"]
IN4["description<br/>optional, and NOT validated by the provider"]
IN5["timeouts<br/>all four keys"]
RES["azurerm_network_manager_network_group.this<br/>a real ARM record, no locks, import guard"]
ABSENT["NO members argument exists<br/>the group is created EMPTY"]
OUT1["id<br/>listed in a rule collection network_group_ids"]
OUT2["this_resource_creates_the_group_but_not_its_membership<br/>constant true"]
OUT3["an_empty_group_makes_every_rule_targeting_it_reach_nothing<br/>constant true"]
OUT4["membership_can_change_with_no_terraform_run_at_all<br/>constant true"]
OUT5["effective_member_type<br/>resolves the provider default"]
OUT6["requires_routing_scope_access_on_the_manager<br/>true for Subnet"]
OUT7["destroy_removes_the_reach_of_every_rule_that_targeted_this_group<br/>constant true"]
IN1 --> RES
IN2 --> RES
IN3 --> RES
IN4 --> RES
IN5 --> RES
RES -.-> ABSENT
RES --> OUT1
RES --> OUT2
RES --> OUT3
RES --> OUT4
RES --> OUT5
RES --> OUT6
RES --> OUT7
style RES fill:#0078D4,stroke:#004578,color:#ffffff
style OUT1 fill:#004578,stroke:#004578,color:#ffffff
style ABSENT fill:#B71C1C,stroke:#7F0000,color:#ffffff
style OUT3 fill:#B71C1C,stroke:#7F0000,color:#ffffff
style OUT7 fill:#B71C1C,stroke:#7F0000,color:#ffffff
style IN1 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style IN2 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style IN3 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style IN4 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style IN5 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style OUT2 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style OUT4 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style OUT5 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style OUT6 fill:#F3F2F1,stroke:#8A8886,color:#201F1E
| Resource | Name | Cardinality |
|---|---|---|
azurerm_network_manager_network_group |
this |
single β a real ARM record under the manager |
Only name and network_manager_id are force-new. The member type 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 |
- π΄ There is NO
membersargument. The group is created empty and populated by static member records or by an Azure Policy rule, neither of which is part of this resource. - π΄ An empty group makes every rule targeting it reach nothing β no error, no warning, nothing in any plan to distinguish it from a working configuration.
- π΄ The delete is FORCED and cannot be disabled. The provider hard-codes the service's force flag.
Microsoft defines it as "Deletes 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. - π
member_type = "Subnet"requiresRoutingamong the manager'sscope_accessesβ documented by the provider, invisible from the manager's Resource ID, therefore not checkable here. - π
member_typeis validated against a GENERATED list, not the documented pair, so the list may hold members the documentation does not name. - π
member_typedefaults toVirtualNetwork, so null in configuration is never the value in force. - π‘
descriptioncarries NO provider validator at all β an empty string is accepted. Both siblings (the verifier workspace and the scope connection) do check this same argument for non-emptiness; this resource does not, so read the rule per resource. On create an empty value is omitted from the payload; on update it is sent as an explicit null, which clears a description set earlier. - β A real ARM record, not a composite. Zero locks; several groups write concurrently and safely.
- β An import guard exists, and all four timeouts β there is a real update function.
| Permission | Scope | Why |
|---|---|---|
Microsoft.Network/networkManagers/networkGroups/read |
the network manager | Refresh, and the create's import check. |
Microsoft.Network/networkManagers/networkGroups/write |
the network manager | Create and update. |
Microsoft.Network/networkManagers/networkGroups/delete |
the group | Destroy β and this delete is forced. |
π The delete permission removes enforcement from live networks without deleting a rule. Because the provider sets the force flag, holding delete here means being able to destroy a group while a deployed configuration is targeting it. Azure performs a background cleanup deployment; the member networks fall back to their own network security groups. No rule changed, and nothing errored.
β οΈ Membership is a separate permission surface, and it is where the blast radius actually lives. Whoever can add a virtual network to this group β by writing a static member, or by writing the Azure Policy rule that populates it β decides which networks every rule targeting this group reaches. None of the permissions above covers that.
- The
Microsoft.Networkresource provider registered in the target subscription. - A network manager, and the intended member virtual networks within its scope.
- π΄ For
member_type = "Subnet":Routingamong the manager'sscope_accesses. - A means of populating the group β static member records, or an Azure Policy rule. Without one the group is real and empty.
- π΄ A deployment of a configuration targeting this group, or nothing reaches its members.
terraform-azurerm-network-manager-network-group/
βββ providers.tf # required_version >= 1.12.0, azurerm ~> 4.0, no provider block
βββ variables.tf # 5 inputs, 5 validations -- the parent, the member type, the description
βββ main.tf # one keystone `this`; ID parsing from the END; the effective member type
βββ outputs.tf # 34 outputs -- id first, then the absent membership, then the destroy
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "production_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-production"
network_manager_id = module.network_manager.id
description = "Production virtual networks, populated by an Azure Policy rule"
}
β οΈ This creates an empty group. Write the description as shown β saying how it is populated is the only record of that fact anywhere on the object.
| Input | Type | Source |
|---|---|---|
name |
string |
the caller |
network_manager_id |
string |
terraform-azurerm-network-manager β id |
member_type |
string |
the caller β Subnet needs Routing on the manager |
description |
string |
the caller |
timeouts |
object(...) |
the caller β four keys |
| Output | Description | Consumed by |
|---|---|---|
id |
A real ARM Resource ID | terraform-azurerm-network-manager-admin-rule-collection β network_group_ids |
this_resource_creates_the_group_but_not_its_membership |
Constant true |
review |
an_empty_group_makes_every_rule_targeting_it_reach_nothing |
Constant true |
security review |
requires_routing_scope_access_on_the_manager |
True for Subnet |
pre-apply review |
1 Β· The smallest real call
module "production_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-production"
network_manager_id = var.network_manager_id
}βΉοΈ Two required arguments, and no way to say what is in the group. That is the resource, not an omission in this module β the schema has no
membersargument at all.
β οΈ The result is a real, referenceable, empty group. See example 2.
2 Β· π΄ The group is created empty
output "what_this_actually_made" {
value = {
exists = module.production_group.id
membership = module.production_group.this_resource_creates_the_group_but_not_its_membership
reaches = module.production_group.an_empty_group_makes_every_rule_targeting_it_reach_nothing
countable = module.production_group.the_module_cannot_report_how_many_members_the_group_has
}
}π΄ A rule collection targeting an empty group applies a complete and correct set of security admin rules to precisely nothing. There is no error, no warning, and nothing in any plan to distinguish that from a working configuration. This is the failure this module exists to name.
π‘ No membership count is emitted, and the reason is attached. A figure here would have to be invented, and an invented blast radius is exactly the number a reviewer would trust.
βΉοΈ Populate the group with static member records, or with an Azure Policy rule β see examples 3 and 4.
3 Β· Static membership
module "production_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-production"
network_manager_id = var.network_manager_id
description = "Production virtual networks, enumerated explicitly as static members"
}
# Static members are their own resource, not an argument here.
resource "azurerm_network_manager_static_member" "production" {
for_each = toset(var.production_virtual_network_ids)
name = "member-${substr(sha1(each.value), 0, 8)}"
network_group_id = module.production_group.id
target_virtual_network_id = each.value
}βΉοΈ Written as a raw resource because the static member is not authored as a module in this library yet β an honest gap rather than a pretend wiring.
π‘
for_each, nevercount. Removing one virtual network from the middle of acountlist re-indexes every member after it, and each of those is a network silently leaving and rejoining an enforced rule set.
β οΈ With static membership the group changes only when Terraform runs β which is the opposite of example 4, and the reason to say which one you chose in the description.
4 Β· Policy-driven membership, and what it means
output "does_this_move_on_its_own" {
value = module.production_group.membership_can_change_with_no_terraform_run_at_all
}π΄ Where membership is driven by an Azure Policy rule, virtual networks join and leave the group as they are created and tagged, entirely outside Terraform. The blast radius of every rule targeting this group therefore moves without any configuration changing.
π‘ That is the intended behaviour of dynamic membership, and it is the whole point of the feature at scale β a new production virtual network inherits the guardrails without anyone remembering to add it.
β οΈ It is also a genuine surprise if it was not the intent. The constant is emitted so that a reviewer reading the configuration knows to go and look at the policy rather than concluding the group is static.
5 Β· The member type, and the default you cannot see
output "member_type" {
value = {
# Read THIS -- the provider defaults an omitted value to "VirtualNetwork".
effective = module.production_group.effective_member_type
chosen = module.production_group.member_type_was_set_explicitly
}
}π‘
member_typedefaults toVirtualNetwork, so null in configuration is never the value in force.effective_member_typeresolves that;member_type_was_set_explicitlydistinguishes a decision from an omission.
βΉοΈ A security admin configuration targets
VirtualNetworkgroups, so the default is the one you usually want.Subnetgroups exist for routing configurations β see example 6.
6 Β· A `Subnet` group needs something on the manager
module "subnet_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-routed-subnets"
network_manager_id = var.network_manager_id
member_type = "Subnet"
}
check "manager_supports_a_subnet_group" {
assert {
condition = !module.subnet_group.requires_routing_scope_access_on_the_manager
error_message = "A Subnet group needs Routing among the manager's scope_accesses; confirm before applying."
}
}
β οΈ The provider documents thatmember_typecan be set toSubnetonly if the parent network manager hasRoutingincluded in itsscope_accesses. That lives on the manager and is not present in its Resource ID, so this module cannot check it β an apply against a manager without it will fail.
π‘ Reported, never refused. The module can see the member type and not the manager's accesses, and refusing on half the operands would reject legal input. The check above is deliberately written as a reminder to confirm, not as a proof.
7 Β· Casing is refused, an unknown value is not
# REFUSED at `terraform plan`, offline and without credentials -- certainly a mistake.
member_type = "virtualnetwork"
member_type = "subnet"
member_type = "VIRTUALNETWORK"# ALLOWED THROUGH -- the provider judges it, not this module.
member_type = "ManagementGroup"π‘ This is deliberate and it is the shape of every enum check in this suite where the provider validates against a GENERATED list. That list holds exactly
SubnetandVirtualNetworktoday β set-equal to what the documentation names β but a later provider release on this pinned line could gain members without the documentation changing.
π΄ So the module rejects the near miss and allows the unknown. Refusing an unrecognised value outright could reject legal input β and a
validation {}failure blocksterraform destroyas well as apply, which would leave a legitimately-created group undestroyable through the module.
8 Β· π΄ The destroy is forced
output "before_you_destroy_this" {
value = {
mechanism = module.production_group.destroy_force_deletes_even_when_a_deployed_configuration_targets_this_group
consequence = module.production_group.destroy_removes_the_reach_of_every_rule_that_targeted_this_group
procedure = module.production_group.destroy_bypasses_the_documented_teardown_order
}
}π΄ All three are constant
true, and they are separate facts. The provider hard-codes the service's force flag with no argument to turn it off. Microsoft defines that flag as "Deletes 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 tofalse.
β οΈ The consequence is the one a plan will not say. Destroying this group deletes no rule at all, and yet every rule that reached its members stops reaching them. One line of1 to destroy; a set of virtual networks losing their centrally enforced guardrails.
π‘ Microsoft's checklist prescribes deleting the connectivity configuration, then every security admin rule associated with the group, then every routing rule, then any attached Azure Policy resources, and only then the group. The forced delete performs a background cleanup deployment instead.
9 Β· A rename does not carry the members
module "production_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-production-v2" # was "ng-production" -- FORCE-NEW
network_manager_id = var.network_manager_id
}π΄ Both force-new fields destroy the group and create a new one with a new Resource ID. Every rule collection referencing the old ID must be updated β and the members do not come with it.
β οΈ A policy-driven group repopulates on its own schedule, so the gap is real but self-closing. A group of static members is empty until those records are recreated, and during that window every rule targeting it reaches nothing.
π‘
lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy. ACanNotDeletemanagement lock prevents deletion but not replacement, so it does not protect against this.
10 Β· Several groups on one manager
locals {
groups = {
production = "Production virtual networks, populated by an Azure Policy rule"
sandbox = "Sandbox virtual networks, static membership"
}
}
module "network_groups" {
for_each = local.groups
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-${each.key}"
network_manager_id = var.network_manager_id
description = each.value
}βΉοΈ The provider takes no locks, so these are written concurrently and safely: each group is its own ARM record rather than an entry in a list on the manager.
π‘
for_each, nevercountβ and note the descriptions carry the one fact nothing else records, which of the two membership models each group uses.
β οΈ A rule collection can target several of these at once; the collection'snetwork_group_idsis where that decision lives, not here.
11 Β· What NOT to do
# WRONG -- the group's own ID where the MANAGER belongs.
network_manager_id = var.network_group_idβΉοΈ Refused at
terraform plan, offline and without credentials. The pattern is anchored at both ends, so anything below the manager is rejected.
# WRONG -- a security admin configuration, or a rule collection.
network_manager_id = var.security_admin_configuration_idβΉοΈ Also refused, for the same reason.
# WRONG -- a Resource ID pasted where a name belongs.
name = "/subscriptions/.../networkGroups/ng-production"βΉοΈ Refused. The provider checks only that the name is non-empty, so this would otherwise reach Azure.
# 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
}
module "production_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-network-group.git?ref=v1.0.0"
name = "ng-production"
network_manager_id = module.network_manager.id
description = "Production virtual networks, populated by an Azure Policy rule"
}
module "security_admin_config" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-manager-security-admin-configuration.git?ref=v1.0.0"
name = "sac-baseline"
network_manager_id = module.network_manager.id
apply_on_network_intent_policy_based_services = ["AllowRulesOnly"]
description = "Baseline security admin rules across production"
}
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 = module.security_admin_config.id
network_group_ids = [module.production_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"
}
output "governance" {
value = {
group_id = module.production_group.id
member_type = module.production_group.effective_member_type
membership_absent = module.production_group.this_resource_creates_the_group_but_not_its_membership
reaches_nothing = module.production_group.an_empty_group_makes_every_rule_targeting_it_reach_nothing
moves_on_its_own = module.production_group.membership_can_change_with_no_terraform_run_at_all
groups_valid = module.high_risk_ports.all_groups_share_the_configurations_network_manager
deny_gap = module.security_admin_config.deny_rules_are_skipped_on_intent_policy_networks
rule_outranks_nsg = module.deny_rdp.terminates_evaluation_before_network_security_groups
needs_deployment = module.security_admin_config.this_configuration_is_inert_until_deployed
}
}π΄ This composition is complete and it enforces nothing. The group has no members, the configuration is not deployed, and
reaches_nothing,membership_absentandneeds_deploymentare alltrue. Every resource applies cleanly.
β οΈ groups_validis the one genuine cross-module check available here β the rule collection derives that this group belongs to the configuration's own network manager, because both Resource IDs carry it.
π‘ The remaining two links are outside this library: the static member or policy rule that populates the group, and the deployment that makes the configuration live.
Required: name, network_manager_id
Optional: member_type, description, timeouts
There is no tags variable β the resource exposes none. Tag the network manager.
There is no members argument β the resource does not have one.
Full input schemas
variable "name" {
type = string
# REQUIRED, FORCE-NEW. Non-empty is the provider's only check. This module
# also refuses a value beginning with "/" -- always an ID pasted for a name.
}
variable "network_manager_id" {
type = string
# REQUIRED, FORCE-NEW. Anchored to .../networkManagers/<manager> at BOTH ends.
# A group can only be targeted by configurations on THE SAME MANAGER.
}
variable "member_type" {
type = string
default = null
# The provider defaults an omitted value to "VirtualNetwork".
# "Subnet" is ONLY VALID if the manager has Routing among its scope_accesses
# -- documented by the provider, invisible from the manager's ID, REPORTED.
#
# The validation REJECTS THE NEAR MISS and ALLOWS THE UNKNOWN, because the
# provider validates against a GENERATED list holding exactly these two today, and a
# the documentation names.
}
variable "description" {
type = string
default = null
# Optional, and unvalidated: an empty string is accepted here (unlike
# omission. The most useful field on this resource: a group shows neither its
# members nor what targets it, so say HOW IT IS POPULATED here.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
# All four exist. THE DELETE TIMEOUT MATTERS: the forced delete triggers a
# background cleanup deployment, and 30m is the provider's default for it.
}| Output | Type | Notes |
|---|---|---|
id |
string |
A real ARM Resource ID β listed in a collection's network_group_ids |
name |
string |
As configured; force-new |
network_manager_id / network_manager_name |
string |
The parent |
resource_group_name / subscription_id |
string |
Parsed from the manager's ID |
this_resource_creates_the_group_but_not_its_membership |
bool |
Constant true |
an_empty_group_makes_every_rule_targeting_it_reach_nothing |
bool |
Constant true β the silent failure |
membership_can_change_with_no_terraform_run_at_all |
bool |
Constant true |
the_module_cannot_report_how_many_members_the_group_has |
bool |
Constant true |
effective_member_type |
string |
Read this, not the raw argument |
member_type_was_set_explicitly |
bool |
A decision versus the provider's default |
holds_virtual_networks / holds_subnets |
bool |
The two branches |
requires_routing_scope_access_on_the_manager |
bool |
True for Subnet |
the_module_cannot_see_the_managers_scope_accesses |
bool |
Constant true |
the_module_cannot_see_which_configurations_target_this_group |
bool |
Constant true |
rules_reaching_this_group_outrank_every_network_security_group_they_reach |
bool |
Constant true |
a_group_is_inert_until_a_configuration_targeting_it_is_deployed |
bool |
Constant true |
destroy_force_deletes_even_when_a_deployed_configuration_targets_this_group |
bool |
Constant true β the mechanism |
destroy_removes_the_reach_of_every_rule_that_targeted_this_group |
bool |
Constant true β the consequence |
destroy_bypasses_the_documented_teardown_order |
bool |
Constant true |
a_force_new_change_destroys_and_recreates_the_group |
bool |
Constant true |
lifecycle_prevent_destroy_is_not_available_to_a_module_caller |
bool |
Constant true |
this_is_a_real_azure_resource_not_a_composite |
bool |
Constant true |
the_provider_takes_no_lock_on_the_network_manager |
bool |
Constant true |
an_import_guard_exists_on_this_resource |
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 |
fields_that_can_change_after_creation |
list(string) |
The member type first |
fields_azure_returns_on_read |
list(string) |
Membership is not in this list |
The resource creates the group and not its membership, and that is the fact everything else follows from.
There is no members argument in the schema. A group is populated by static member records or dynamically by
an Azure Policy rule, both of which live outside this resource β so an apply that succeeds proves the group
exists and proves nothing about what is in it.
The failure that produces is silent. A rule collection targeting an empty group applies a complete and correct set of security admin rules to precisely nothing: no error, no warning, and nothing in any plan to distinguish it from a working configuration. That is why the absence is emitted as four separate constants rather than one β that the resource does not carry membership, that an empty group reaches nothing, that membership can move without Terraform, and that no count can be derived are four distinct facts, and the second is the one that produces a security gap.
No membership count is emitted, deliberately. A figure would have to be invented, and an invented blast radius is exactly the number a reviewer would trust. The reason is attached to the output rather than left implicit.
The member type has an invisible default. The provider defaults an omitted value to VirtualNetwork, so
effective_member_type is what a reader needs. And Subnet is not simply an alternative: the provider
documents that it requires Routing among the manager's scope_accesses, which is not present in the
manager's Resource ID and therefore cannot be checked here. It is reported, because refusing on half the
operands would reject legal input.
The member type validation rejects the near miss and allows the unknown, because the provider validates
against a generated list which currently equals the documented pair. virtualnetwork is certainly a mistake;
ManagementGroup might be a value the documentation has not caught up with. Refusing it would risk making a
legitimately-created group undestroyable through the module, since a validation {} failure blocks
terraform destroy as well as apply.
The destroy is forced, and the consequence is not the mechanism. 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. Destroying this group deletes no rule at all β and yet every rule that reached its members stops reaching them. Microsoft's teardown checklist prescribes removing the associated configurations, rules and policy resources first; the forced delete performs a background cleanup deployment instead.
A rename does not carry the members. Both force-new fields produce a new Resource ID, so every rule
collection referencing the old one must be updated, and a statically-populated group is empty until those
records are recreated. lifecycle is not valid inside a module block, so a caller cannot add
prevent_destroy; a CanNotDelete lock prevents deletion but not replacement.
| Concern | This module's position | What the caller must type to change it |
|---|---|---|
| The absent membership | Four constants, because they are four distinct facts | β |
| A membership count | Refused, with the reason attached β never guessed | β |
| The member type | Effective value + explicit-versus-default | β |
An unknown member_type |
Allowed through; only the casing near-miss is refused | β |
The Routing requirement |
Reported, never refused | assert on the flag |
| Destroy semantics | Three constants β mechanism, consequence, bypassed procedure | β |
| The parent ID | Anchored at both ends | β |
tags |
Absent by design β the resource exposes none | tag the network manager |
π There is no secure-by-default choice to make on this resource: it has no security-relevant setting. The risk here is entirely in what the group contains and what targets it, and this module can see neither β so it says so, loudly, rather than letting a clean apply read as coverage.
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 confirming how the group will be populated.
What terraform validate covers, offline and without credentials:
- A non-empty
namethat is not a Resource ID. - The anchored
network_manager_idcheck, at both ends. - The
member_typecasing near-miss β while allowing an unrecognised value through. - The
descriptionnon-empty rule, mirroring the provider.
What only terraform plan exercises (credentials required):
- That the manager exists, and the import guard if a group of this name is already on it.
What only terraform apply reveals:
- Whether the manager has
Routingamong its scope accesses, for aSubnetgroup. - Whether an unrecognised
member_typeis in fact legal.
What nothing reveals at all:
- What the group contains, or how many members it has.
- Which configurations target it.
- Whether any of them is deployed.
βΉοΈ
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 both branches ofholds_virtual_networks,holds_subnets, theRoutingreport and the explicit-versus-default distinction, plus a positive fixture proving an unknownmember_typeis accepted rather than refused.
Outputs:
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-eastus/providers/Microsoft.Network/networkManagers/nm-corp/networkGroups/ng-production"
name = "ng-production"
network_manager_name = "nm-corp"
effective_member_type = "VirtualNetwork"
member_type_was_set_explicitly = false
holds_virtual_networks = true
requires_routing_scope_access_on_the_manager = false
this_resource_creates_the_group_but_not_its_membership = true
an_empty_group_makes_every_rule_targeting_it_reach_nothing = true
membership_can_change_with_no_terraform_run_at_all = true
destroy_removes_the_reach_of_every_rule_that_targeted_this_group = true
fields_azure_returns_on_read = [
"name",
"member_type",
"description",
]
βΉοΈ
fields_azure_returns_on_readis worth reading for what is missing. Membership is not in the list, because membership is not on this resource β so drift in what the group contains is not detectable here at all, while drift in its member type or description is.
| Symptom | Cause | Fix |
|---|---|---|
must not be empty or whitespace |
A blank name | Non-empty is the provider's only check |
must be a bare group name |
A Resource ID passed as name |
Pass only the final segment |
must be a network manager Resource ID |
A group or configuration passed instead | The pattern is anchored at both ends |
is case-sensitive |
virtualnetwork or subnet |
Use VirtualNetwork or Subnet |
must not be empty or whitespace when set |
description = "" |
Omit the argument instead |
| Apply fails with a requires-import error | A group of this name exists on the manager | Import it, or choose another name |
Apply fails on a Subnet group |
The manager lacks Routing scope access |
Add it to the manager |
An unrecognised member_type was refused |
This module allows an unknown value through deliberately, and the provider's own schema check then refuses it β a schema check, not an apply-stage one, so it surfaces during plan rather than against Azure | Use exactly VirtualNetwork or Subnet; the provider's generated list is authoritative |
| Rules reach nothing | The group is empty | Add static members, or a policy rule |
| More networks affected than expected | Policy-driven membership grew | Expected; read the policy, not this module |
| The group changed with no Terraform run | Same reason | Dynamic membership is outside Terraform |
| A rule collection rejected this group | It belongs to a different network manager | The collection derives and reports that |
| Nothing is enforced | No configuration targeting it is deployed | Deploy the configuration to the region |
terraform destroy removed live enforcement |
The delete is forced and cannot be disabled | Undeploy first if that was not the intent |
| A rename emptied the group | Force-new produces a new ID; members do not follow | Recreate the static members |
azurerm_network_manager_network_groupβ provider reference- Network groups in Azure Virtual Network Manager β static versus dynamic membership
- Security admin rules β what reaching a group's members actually means
- Remove and update components checklist β the teardown order the forced delete bypasses
- Sibling modules:
terraform-azurerm-network-manager,terraform-azurerm-network-manager-security-admin-configuration,terraform-azurerm-network-manager-admin-rule-collection,terraform-azurerm-network-manager-admin-rule,terraform-azurerm-resource-group - This module's
SCOPE.mdβ the cross-module contract
π "Infrastructure as Code should be standardized, consistent, and secure."