Python Azure VNet and NSG association census
Address space is the boundary. Association is where the filter attaches. Priority is the order of the filter.
<!-- hal:authoritative:yaml -->
Address space is the boundary. Association is where the filter attaches. Priority is the order of the filter.
§I — Frame
Thursday named GatewayClass and HTTPRoute on GKE. That leftover stays on its shelf.
Today the cert seat is AZ-900 again. Counter 11 mod 4 is seat 3. Prior Microsoft visits opened hierarchy (08-11), Entra identity (08-23), and storage redundancy (09-04). The study guide's networking bullets are still open: describe Azure virtual networks, VPN, ExpressRoute, and public/private endpoints at the fundamentals grain. Ops takes the concrete surface a Python census can print without remediating anything.
The tool talks to azure.mgmt.network.NetworkManagementClient. It lists virtual networks, prints address space and subnet prefixes, then lists network security groups with their subnet and NIC association IDs and custom security rules ordered by priority. It does not begin_create_or_update. It does not rewrite rules. It does not move associations. A census that mutates the filter is no longer a census.
§II — Three shelves on one network
| Shelf | What it does | What it does not do |
|---|---|---|
| VNet + subnet | Owns address space and CIDR slices | Filter packets by itself |
| NSG association | Attaches a rule set to a subnet and/or a NIC | Replace the address space |
| Security rules | Allow or deny by 5-tuple, evaluated by ascending priority | Survive as effective policy if the NSG is unassociated |
az-900 README puts VNet as the private boundary and NSG as the filter on interfaces (and, in practice, on subnets). Confusing the boundary with the filter is the exam trap and the ops trap. A VNet with no NSG associations still has default Azure connectivity behavior; an NSG with rules but zero associations filters nothing.
§III — Mechanism: the census, not the remediation
Reuse DefaultAzureCredential from 08-23. Do not reteach it. NetworkManagementClient(credential, subscription_id) exposes virtual_networks, subnets, network_security_groups, and security_rules.
from dataclasses import dataclass, field
from azure.identity import DefaultAzureCredential
from azure.mgmt.network import NetworkManagementClient
@dataclass(frozen=True)
class RuleRow:
priority: int
name: str
direction: str
access: str
protocol: str
dest_port: str
@dataclass(frozen=True)
class NsgRow:
name: str
resource_group: str
subnet_ids: tuple[str, ...]
nic_ids: tuple[str, ...]
rules: tuple[RuleRow, ...] = field(default_factory=tuple)
def list_vnets(client: NetworkManagementClient):
for vnet in client.virtual_networks.list_all():
spaces = list(vnet.address_space.address_prefixes or [])
subnets = [
{
"name": s.name,
"prefix": (s.address_prefix or (s.address_prefixes or [None])[0]),
"nsg": (s.network_security_group.id if s.network_security_group else None),
}
for s in (vnet.subnets or [])
]
yield {"name": vnet.name, "spaces": spaces, "subnets": subnets}
def list_nsgs(client: NetworkManagementClient):
for nsg in client.network_security_groups.list_all():
subnet_ids = tuple(a.id for a in (nsg.subnets or []) if a and a.id)
nic_ids = tuple(a.id for a in (nsg.network_interfaces or []) if a and a.id)
rules = []
for r in (nsg.security_rules or []):
if r.priority is None:
continue
rules.append(
RuleRow(
priority=int(r.priority),
name=r.name or "",
direction=str(r.direction),
access=str(r.access),
protocol=str(r.protocol),
dest_port=str(r.destination_port_range or r.destination_port_ranges),
)
)
rules.sort(key=lambda row: row.priority)
rg = (nsg.id or "").split("/")[4] if nsg.id else ""
yield NsgRow(nsg.name or "", rg, subnet_ids, nic_ids, tuple(rules))
Three read paths matter.
Path one. VNet list. address_space.address_prefixes is the private CIDR claim. Each subnet carries its own prefix and an optional network_security_group.id. A subnet row with nsg: null is a finding: traffic at that slice is not filtered by a subnet-level NSG.
Path two. NSG list. The NSG object returns subnets and network_interfaces association collections. Print both. Same NSG can attach to many subnets and many NICs. Zero associations with nonzero custom rules is a second finding: rules exist and attach nowhere.
Path three. Rule order. Custom priorities sit in 100–4096. Defaults live at 65000+. Sort custom rules ascending. Lower number wins; first match stops evaluation. Destination port is a field on the rule, not the priority. Printing priority next to dest_port on every row keeps that distinction visible.
Inbound evaluation when both subnet and NIC NSGs exist: subnet NSG first, then NIC. Outbound reverses that. Traffic must be allowed at both levels. The census does not compute effective rules; it prints the association graph so a human can open Effective security rules in Network Watcher next.
§IV — Findings the census is allowed to shout
- Subnet with address prefix and
nsg: null. - NSG with custom rules and empty
subnet_idsandnic_ids. - Two custom rules with the same 5-tuple shape where a higher priority (larger number) can never fire.
- Deny at priority 100 on a port the team believes is open because a later Allow exists at 400.
It never opens a port. It never attaches an NSG. Maghrib owns quiz and any lab-ref drill against the Cert slot later.
§V — Close instruction
Run the census against one subscription. Paste the VNet table and the NSG association table into the day note. Mark every subnet without an NSG and every NSG without an association. Leave remediation for a change window with a ticket. Pair: Dev teaches order=True and field(compare=False) so priority sorts without dragging port strings into comparisons; Cert names VNet, NSG, peering, and VPN at AZ-900 grain.