Hedronite · Ops Lesson · 01-Earth-DevOps / Azure · Sat 2026-09-19

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.

Lesson Class: Ops (DevOps + Python + Azure Networking)
Cloud Referent: Azure VNet + subnet + NSG association + security rule priority
Paired Dev: Python dataclass order + field(compare=False)
Paired Cert: AZ-900 VNet / NSG / peering / VPN
Grounding: az-900 README · azure-core-services Networking
VNet
Address space and subnet CIDR. Boundary, not filter.
Association
NSG attaches to subnet and/or NIC.
Priority
100–4096 ascending; first match wins.
Print the association graph. Do not rewrite the rules.

<!-- 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

ShelfWhat it doesWhat it does not do
VNet + subnetOwns address space and CIDR slicesFilter packets by itself
NSG associationAttaches a rule set to a subnet and/or a NICReplace the address space
Security rulesAllow or deny by 5-tuple, evaluated by ascending prioritySurvive 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

  1. Subnet with address prefix and nsg: null.
  2. NSG with custom rules and empty subnet_ids and nic_ids.
  3. Two custom rules with the same 5-tuple shape where a higher priority (larger number) can never fire.
  4. 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.