Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Network Manager Network Group Terraform Module

The unit a security admin configuration targets β€” created here, and populated somewhere else entirely. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 🎯 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 Subnet needs Routing on 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 members argument 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.


❀️ Support this project

If this module saved you time:


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

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
Loading

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.


🧬 What this module builds

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
Loading
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.


βœ… 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

  • πŸ”΄ There is NO members argument. 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" requires Routing among the manager's scope_accesses β€” documented by the provider, invisible from the manager's Resource ID, therefore not checkable here.
  • 🟠 member_type is validated against a GENERATED list, not the documented pair, so the list may hold members the documentation does not name.
  • 🟠 member_type defaults to VirtualNetwork, so null in configuration is never the value in force.
  • 🟑 description carries 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.

πŸ”‘ Required Azure RBAC Roles / Permissions

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.


Azure Prerequisites

  • The Microsoft.Network resource provider registered in the target subscription.
  • A network manager, and the intended member virtual networks within its scope.
  • πŸ”΄ For member_type = "Subnet": Routing among the manager's scope_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.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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.


πŸ”Œ Cross-Module Contract

Consumes

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

Emits

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

πŸ“š Example Library

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 members argument 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, never count. Removing one virtual network from the middle of a count list 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_type defaults to VirtualNetwork, so null in configuration is never the value in force. effective_member_type resolves that; member_type_was_set_explicitly distinguishes a decision from an omission.

ℹ️ A security admin configuration targets VirtualNetwork groups, so the default is the one you usually want. Subnet groups 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 that member_type can be set to Subnet only if the parent network manager has Routing included in its scope_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 Subnet and VirtualNetwork today β€” 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 blocks terraform destroy as 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 to false.

⚠️ 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 of 1 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.

πŸ’‘ lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy. A CanNotDelete management 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, never count β€” 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's network_group_ids is 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_absent and needs_deployment are all true. Every resource applies cleanly.

⚠️ groups_valid is 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.


πŸ“₯ Inputs

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.
}

🧾 Outputs

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

🧠 Architecture Notes

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.


🧱 Design Principles

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.


πŸš€ 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 confirming how the group will be populated.


πŸ§ͺ Testing

What terraform validate covers, offline and without credentials:

  • A non-empty name that is not a Resource ID.
  • The anchored network_manager_id check, at both ends.
  • The member_type casing near-miss β€” while allowing an unrecognised value through.
  • The description non-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 Routing among its scope accesses, for a Subnet group.
  • Whether an unrecognised member_type is 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 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 both branches of holds_virtual_networks, holds_subnets, the Routing report and the explicit-versus-default distinction, plus a positive fixture proving an unknown member_type is accepted rather than refused.


πŸ’¬ Example Output

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_read is 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.


πŸ” Troubleshooting

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

πŸ”— Related Docs


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