Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Palo Alto Local Rulestack Prefix List Terraform Module

Creates a named set of CIDR blocks inside a Cloud NGFW local rulestack, for rules to match on. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • πŸ“‡ Creates one azurerm_palo_alto_local_rulestack_prefix_list β€” a reusable, named group of CIDR blocks that rules match against by ID.
  • πŸ”΄ Reports the trap that costs the most time here: the name cannot be changed, and the provider does not stop you trying. It is not force-new, the update never sends it, and the read restores it β€” so a rename applies successfully, changes nothing, and returns on every later plan.
  • βœ… Reports the good news too: create, update and delete all commit the rulestack, so removing a prefix list takes effect immediately β€” unlike removing a rule.
  • 🎯 Emits the shape of the address set β€” covers_every_ipv4_address, broadest_prefix_length, duplicate_prefix_count, every_prefix_is_a_single_address β€” so a review reads the list rather than counting it.
  • ⚠️ Explains the difference from a rule: the literal "any" is rejected here, and a bare IP needs an explicit /32.

πŸ’‘ Why it matters: a prefix list is how a rulestack stops repeating itself β€” one named object, referenced by many rules on either side of the match. That also makes it the quiet blast radius: widening a prefix list widens every rule that references it, in one apply, with no rule appearing in the plan. Twenty derived outputs exist so the widening is visible before it happens.


❀️ Support this project

If this module saves you time:


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

flowchart TB
  RG["terraform-azurerm-resource-group"]
  VNET["terraform-azurerm-virtual-network: the VNet a VNET-deployed firewall is placed into, with its trusted and untrusted subnets"]
  VWAN["terraform-azurerm-virtual-wan"]
  VHUB["terraform-azurerm-virtual-hub: the Virtual WAN hub. It supplies BOTH the resource group and the region of anything registered against it"]
  PUBIP["terraform-azurerm-public-ip: the addresses a firewall presents, and its egress NAT addresses. Passed by Resource ID"]
  KV["terraform-azurerm-key-vault: holds the certificate a non-self-signed rulestack certificate points at, and its private key"]

  RULESTACK["terraform-azurerm-palo-alto-local-rulestack: six threat-prevention engines and nothing else. Creating it does NOT commit it"]
  RULE["terraform-azurerm-palo-alto-local-rulestack-rule: the traffic policy. PRIORITY is its identity and is force-new. Creating or updating a rule COMMITS the whole rulestack -- deleting one does NOT"]
  PREFIXLIST["terraform-azurerm-palo-alto-local-rulestack-prefix-list: a named set of CIDR blocks, every entry validated as a real CIDR. Usable from BOTH sides of a rule's match"]
  FQDNLIST["terraform-azurerm-palo-alto-local-rulestack-fqdn-list: a named set of domain names. The provider validates only that each entry is NOT EMPTY, and the list is usable from a rule's DESTINATION ONLY"]
  CERTIFICATE["terraform-azurerm-palo-alto-local-rulestack-certificate: either self-signed or a Key Vault reference, and THREE of its arguments are force-new. Its DELETE does not commit"]
  TRUSTASSOC["terraform-azurerm-palo-alto-local-rulestack-outbound-trust-certificate-association: a SINGLETON that sets one property on the rulestack"]
  UNTRUSTASSOC["terraform-azurerm-palo-alto-local-rulestack-outbound-untrust-certificate-association: the independent twin, and a true clone of it"]
  FWVNET["terraform-azurerm-palo-alto-next-generation-firewall-virtual-network-local-rulestack: THE RESOURCE THAT MAKES ALL OF THE ABOVE INSPECT TRAFFIC. Takes its REGION from the rulestack, carries no policy of its own, and takes HOURS to create"]
  FWVHUB["terraform-azurerm-palo-alto-next-generation-firewall-virtual-hub-local-rulestack: the Virtual WAN twin of the VNet firewall, and NOT a clone of it. It REQUIRES an appliance slot, and Azure -- not the caller -- assigns its trusted and untrusted subnets. FIVE force-new fields against the VNet variant's three"]
  APPLIANCE["terraform-azurerm-palo-alto-virtual-network-appliance: the family's odd one out. It creates a MICROSOFT.NETWORK resource, not a Cloud NGFW one, and the only thing making it Palo Alto is a delegation string the provider hardcodes. No locks, no commits, no rulestack"]
  FWVHUBPANO["terraform-azurerm-palo-alto-next-generation-firewall-virtual-hub-panorama: the same Virtual WAN plumbing as its rulestack-managed twin, with policy moved OUT of Azure. It takes NO rulestack, takes a REGION as a real argument, takes NO locks and NO commits, and has SIX force-new fields"]

  MODEL["A FIREWALL PICKS EXACTLY ONE POLICY MANAGEMENT MODEL: an Azure Rulestack managed natively in Azure, Palo Alto Panorama, or Strata Cloud Manager. That single choice is why there are SIX firewall resources -- two deployment models times three management models -- and it is what decides whether this rulestack is used at all"]

  PANORAMA["Palo Alto Panorama: NOT an Azure resource and NOT a Terraform resource. Where every rule, address object, service and profile lives for a Panorama-managed firewall. Terraform passes one opaque base64 registration blob and can see none of the policy"]

  FWVNETPANO["terraform-azurerm-palo-alto-next-generation-firewall-virtual-network-panorama: Panorama policy on the Virtual Network deployment model, which CLOSES the Panorama pair. It needs NO appliance, its network profile has NO force-new fields so the VNet and both subnets change in place, and it is the only firewall here whose DELETE allows 3 hours"]
  FWVNETSCM["terraform-azurerm-palo-alto-next-generation-firewall-virtual-network-strata-cloud-manager: policy in Palo Alto SaaS. It carries a managed identity -- a thing only the two Strata variants in this family have -- and its tenant name is validated by a LIVE SUBSCRIPTION-SCOPED API CALL AT PLAN TIME -- so a plan needs real Azure access. Its default billing plan is NOT the stop-sell one"]
  FWVHUBSCM["terraform-azurerm-palo-alto-next-generation-firewall-virtual-hub-strata-cloud-manager: the sixth and last firewall, CLOSING THIS FAMILY. It combines the Virtual WAN plumbing -- appliance prerequisite, Azure-assigned subnets, SIX force-new fields -- with Strata policy and its PLAN-TIME tenant check. Every fact it states already had a name elsewhere in the family"]
  SCM["Palo Alto Strata Cloud Manager: a SaaS tenant, NOT an Azure resource and NOT a Terraform resource. Where every rule lives for a Strata-managed firewall. Terraform passes only its NAME, and the provider checks that name exists at PLAN time"]
  UAI["terraform-azurerm-user-assigned-identity: the identity a Strata-managed firewall can reference. USER-ASSIGNED ONLY -- there is no system-assigned option -- so it is a prerequisite resource, and it is where principal_id and client_id are read from"]

  RG -->|"resource_group_name and location. BOTH force-new, along with the name"| RULESTACK
  RG -->|"resource_group_name"| VNET
  RG -->|"resource_group_name"| VWAN
  RG -->|"resource_group_name, force-new. NOTE the firewall takes no location argument at all"| FWVNET
  RG -->|"resource_group_name, force-new. It need NOT be the hub's resource group, and no location argument exists here either"| FWVHUB
  RG -->|"resource_group_name AND location, both force-new. BOTH Panorama-managed firewalls take a region as an argument, because neither has a rulestack to read one from"| FWVHUBPANO
  VWAN -->|"the virtual wan id"| VHUB

  RULESTACK -->|"the RULESTACK ID, and a rule is where policy actually lives. A create or update here COMMITS the rulestack"| RULE
  RULESTACK -->|"the rulestack id, force-new. All three writes lock AND commit, delete included"| PREFIXLIST
  RULESTACK -->|"the rulestack id, force-new. Its create does NOT wait for its own write"| FQDNLIST
  RULESTACK -->|"the rulestack id, force-new. Unlike the two lists, this child's DELETE does not commit"| CERTIFICATE
  KV -.->|"a VERSIONLESS certificate URI. Under the pinned provider line the validator accepts any nested item"| CERTIFICATE

  PREFIXLIST -->|"the prefix list ID, which BOTH a rule's source and its destination can reference"| RULE
  FQDNLIST -->|"the fqdn list ID, which ONLY a rule's destination can reference"| RULE
  CERTIFICATE -->|"the certificate id, for a rule's inbound SSL inspection"| RULE
  CERTIFICATE -->|"the certificate ID, and also the association's own Terraform resource id"| TRUSTASSOC
  CERTIFICATE -->|"the certificate ID, same contract as the trust twin"| UNTRUSTASSOC
  TRUSTASSOC -.->|"writes securityServices.outboundTrustCertificate ON THE RULESTACK, then commits"| RULESTACK
  UNTRUSTASSOC -.->|"writes securityServices.outboundUnTrustCertificate ON THE RULESTACK, then commits"| RULESTACK

  RULESTACK -->|"THE PAYOFF EDGE: the rulestack id, NOT force-new so the policy can be swapped in place. It also DECIDES THE FIREWALL'S REGION"| FWVNET
  VNET -->|"virtual_network_id, plus the trusted and untrusted subnet ids. Both subnets are OPTIONAL to the provider and needed in practice"| FWVNET
  PUBIP -->|"public ip Resource IDs: the addresses presented, the egress NAT addresses, and any DNAT frontend"| FWVNET

  RULESTACK -->|"THE SAME PAYOFF EDGE, on the Virtual WAN side: the rulestack id, not force-new, and it decides this firewall's region too"| FWVHUB
  VHUB -->|"virtual_hub_id, FORCE-NEW here unlike anything in the VNet variant's profile. No subnet ids are passed: Azure assigns them inside the hub"| FWVHUB
  PUBIP -->|"public ip Resource IDs: the addresses presented, the egress NAT addresses, and any DNAT frontend"| FWVHUB
  VHUB -->|"virtual_hub_id, force-new and the ONLY input besides the name. The provider takes the resource group from this ID and READS the hub for its location"| APPLIANCE
  APPLIANCE -->|"network_virtual_appliance_id: a REQUIRED and FORCE-NEW hard prerequisite. Create the appliance first, and note it needs MICROSOFT.NETWORK rights rather than Cloud NGFW ones"| FWVHUB

  VHUB -->|"virtual_hub_id, force-new. THE SAME provider schema helper the rulestack-managed hub firewall uses, so the whole network profile behaves identically"| FWVHUBPANO
  APPLIANCE -->|"network_virtual_appliance_id: the SAME required, force-new prerequisite. The deployment model is unchanged by moving policy out of Azure"| FWVHUBPANO
  PUBIP -->|"public ip Resource IDs: the addresses presented, the egress NAT addresses, and any DNAT frontend"| FWVHUBPANO
  PANORAMA -.->|"a base64-encoded registration config, supplied as ONE OPAQUE ARGUMENT. It carries the credential that authenticates the firewall, and Azure decodes it and reports its parts back as computed attributes"| FWVHUBPANO

  RG -->|"resource_group_name AND location, both force-new. Like its Virtual WAN twin and unlike the rulestack-managed firewalls, this one takes a region"| FWVNETPANO
  VNET -->|"virtual_network_id plus the trusted and untrusted subnet ids. NONE of them force-new here, so all three change in place -- and both subnets are OPTIONAL to the provider and needed in practice"| FWVNETPANO
  PUBIP -->|"public ip Resource IDs: the addresses presented, the egress NAT addresses, and any DNAT frontend"| FWVNETPANO
  PANORAMA -.->|"the SAME opaque base64 registration argument as the Virtual WAN variant. No appliance and no rulestack are involved at all"| FWVNETPANO

  RG -->|"resource_group_name AND location, both force-new. Every firewall whose policy lives outside Azure takes a region, because none of them has a rulestack to read one from"| FWVNETSCM
  VNET -->|"virtual_network_id plus the trusted and untrusted subnet ids. NONE force-new, and both subnets are OPTIONAL to the provider and needed in practice"| FWVNETSCM
  PUBIP -->|"public ip Resource IDs: the addresses presented, the egress NAT addresses, and any DNAT frontend"| FWVNETSCM
  SCM -.->|"strata_cloud_manager_tenant_name: a NAME, not a Resource ID and not a blob. The provider lists the subscription's available tenants during PLAN and fails the plan if this one is not among them"| FWVNETSCM
  UAI -.->|"identity_ids, optional. What the identity GRANTS is undocumented -- the provider's own docs for this block are copied from an unrelated resource -- so this suite attaches none by default"| FWVNETSCM

  RG -->|"resource_group_name AND location, both force-new. It need NOT be the hub's resource group"| FWVHUBSCM
  VHUB -->|"virtual_hub_id, FORCE-NEW. The SAME provider schema helper the other two Virtual WAN firewalls use, so the whole network profile behaves identically"| FWVHUBSCM
  APPLIANCE -->|"network_virtual_appliance_id: the SAME required, force-new prerequisite, needing MICROSOFT.NETWORK rights rather than Cloud NGFW ones"| FWVHUBSCM
  PUBIP -->|"public ip Resource IDs: the addresses presented, the egress NAT addresses, and any DNAT frontend"| FWVHUBSCM
  SCM -.->|"strata_cloud_manager_tenant_name, checked against the subscription's available tenants during PLAN -- the same live call as its Virtual Network twin"| FWVHUBSCM
  UAI -.->|"identity_ids, optional and of undocumented purpose. The only two firewalls here that can take an identity are the two Strata variants"| FWVHUBSCM

  MODEL -.->|"choosing Azure Rulestack is what makes this rulestack relevant"| FWVNET
  MODEL -.->|"choosing Azure Rulestack, on the Virtual WAN deployment model"| FWVHUB
  MODEL -.->|"choosing Panorama on the Virtual WAN deployment model: the rulestack and every module above it become irrelevant"| FWVHUBPANO
  MODEL -.->|"choosing Panorama on the Virtual Network deployment model: with both Panorama firewalls authored, this branch of the family is complete"| FWVNETPANO
  MODEL -.->|"choosing Strata Cloud Manager on the Virtual Network deployment model: the rulestack branch becomes irrelevant, and policy leaves Azure entirely"| FWVNETSCM
  MODEL -.->|"choosing Strata Cloud Manager on the Virtual WAN deployment model. With this authored, ALL SIX firewall resources and every rulestack child are modules -- the family is COMPLETE"| FWVHUBSCM

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class RULESTACK,RULE,PREFIXLIST,FQDNLIST,CERTIFICATE,TRUSTASSOC,UNTRUSTASSOC,FWVNET,FWVHUB,APPLIANCE,FWVHUBPANO,FWVNETPANO,FWVNETSCM,FWVHUBSCM me
  class MODEL keystone
  class RG,VNET,VWAN,VHUB,PUBIP,KV,PANORAMA,SCM,UAI sibling
Loading

🧬 What this module builds

flowchart TB
  IN_PARENT["rulestack_id: the parent rulestack. FORCE-NEW, and the ONLY force-new argument on this resource"]
  IN_NAME["name: alphanumerics and dashes, validated by the RULE's validator rather than one of its own. NOT force-new and NOT updatable -- see the trap below"]
  IN_PREFIXES["prefix_list: at least one entry, every entry validated with IsCIDR. The literal any is REJECTED here even though a rule accepts it, and a bare IP needs an explicit /32"]
  IN_DOCS["audit_comment and description: free-form, both optional, and both sent differently on create than on update when emptied"]

  TRAP["THE RENAME TRAP: name is required, not force-new, never sent on update, and restored from the Resource ID on read. So a rename plans, applies, changes nothing, and comes back on the next plan forever. Replace the resource instead"]

  THIS["azurerm_palo_alto_local_rulestack_prefix_list.this"]

  WRITES["CREATE, UPDATE and DELETE each LOCK the parent rulestack and then COMMIT it. Reads do neither. So a for_each serializes and pays a commit per entry -- and a DELETE takes effect immediately, unlike a rule's"]
  INERT["A prefix list filters nothing on its own. It matters only when a rule references its ID, and nothing here can see whether one does"]

  OUT_ID["id, name, rulestack_id, prefix_list, audit_comment and description as applied"]
  OUT_PARENT["rulestack_name, resource_group_name and rulestack_path_within_the_subscription, parsed from the parent id"]
  OUT_SHAPE["prefix_count, distinct_prefix_count, duplicate_prefix_count and prefixes_sorted for comparison"]
  OUT_WIDTH["covers_every_ipv4_address, covers_every_ipv6_address, broadest_prefix_length and narrowest_prefix_length"]
  OUT_KIND["single_address_prefixes, every_prefix_is_a_single_address, the IPv6 trio, and is_undocumented"]
  OUT_CONST["ten constants, led by the rename trap and the commit-on-delete contrast with a rule"]

  IN_PARENT --> THIS
  IN_NAME --> THIS
  IN_PREFIXES --> THIS
  IN_DOCS --> THIS
  IN_NAME -.->|"the trap this module reports"| TRAP

  THIS --> WRITES
  THIS -.-> INERT
  THIS --> OUT_ID
  THIS --> OUT_PARENT
  THIS --> OUT_SHAPE
  THIS --> OUT_WIDTH
  THIS --> OUT_KIND
  THIS --> OUT_CONST

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002438,color:#ffffff
  classDef sibling fill:#F3F6F9,stroke:#8A9BA8,color:#1B1F23
  class THIS keystone
  class OUT_ID,OUT_PARENT,OUT_SHAPE,OUT_WIDTH,OUT_KIND,OUT_CONST me
  class IN_PARENT,IN_NAME,IN_PREFIXES,IN_DOCS,TRAP,WRITES,INERT sibling
Loading

Resource inventory

Resource Count Notes
azurerm_palo_alto_local_rulestack_prefix_list 1 (this) One force-new argument: rulestack_id. The name is immutable in practice but not marked so

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None here. The caller configures the provider, its authentication, and its mandatory features {} block
ARM resource type PaloAltoNetworks.Cloudngfw/localRulestacks/prefixlists

Schema notes that bite

  • πŸ”΄ name CANNOT BE CHANGED, and nothing enforces that. It is required and not force-new, so a change does not trigger replacement. The update path never sends it. The read path restores it from the Resource ID, which never changes. Net effect: a rename plans, applies cleanly, changes nothing, and reappears on every subsequent plan. Use terraform apply -replace=... instead.
  • βœ… Create, update AND delete each lock the parent rulestack and then commit it. All three. So a removed prefix list takes effect immediately β€” the sharp edge on the sibling rule module does not apply here.
  • ⚠️ rulestack_id is the only force-new argument, so a prefix list cannot be moved between rulestacks.
  • ⚠️ The literal "any" is rejected. A rule's source/destination CIDR lists accept it; IsCIDR here does not. Write 0.0.0.0/0.
  • ⚠️ A bare IP address is rejected β€” 10.0.0.1 fails, 10.0.0.1/32 passes. The provider's own message does not say so.
  • ⚠️ At least one entry is required (MinItems: 1). An empty list is not a way to say "no addresses".
  • ⚠️ Duplicates are accepted silently. Harmless in effect, misleading in review; reported as duplicate_prefix_count.
  • ⚠️ IPv6 CIDRs pass validation because IsCIDR accepts them. Whether Cloud NGFW treats them identically is not visible from Terraform.
  • ⚠️ The name validator is the RULE's β€” the provider uses LocalRuleStackRuleName here rather than a prefix-list-specific one, and it carries a TODO - Check this comment in the provider's source. The sibling FQDN list has its own.
  • ℹ️ There are no computed attributes. The provider's Attributes() map is empty, so id is the only thing Azure adds.
  • ℹ️ All four timeouts are honored, each declared on its own CRUD function. Read defaults to 5 minutes; the other three to 30, because they wait on a commit.

πŸ”‘ Required Azure RBAC Roles / Permissions

Permission Scope Why
PaloAltoNetworks.Cloudngfw/localRulestacks/prefixlists/write the parent rulestack Create and update
PaloAltoNetworks.Cloudngfw/localRulestacks/prefixlists/read the parent rulestack Refresh, and the pre-create existence check
PaloAltoNetworks.Cloudngfw/localRulestacks/prefixlists/delete the parent rulestack Destroy
PaloAltoNetworks.Cloudngfw/localRulestacks/commit/action the parent rulestack Required by all three writes, which each commit

πŸ”’ This permission is quieter than the rule's, and nearly as powerful. Nobody who edits a prefix list appears in a rule's plan, yet adding 0.0.0.0/0 to a list that ten rules reference widens all ten at once. Review changes to shared prefix lists the way you would review the rules themselves.

⚠️ commit/action is not optional here. Unlike the parent rulestack, whose create does not commit, every write on this resource does β€” so an identity without it fails after the object is written.

ℹ️ No credential is read or emitted. Plan access here is not credential access.


Azure Prerequisites

  • A Cloud NGFW by Palo Alto Networks Marketplace subscription, and the PaloAltoNetworks.Cloudngfw resource provider registered.
  • An existing local rulestack β€” see terraform-azurerm-palo-alto-local-rulestack.
  • Rules that reference this list, for it to affect any traffic β€” see terraform-azurerm-palo-alto-local-rulestack-rule.
  • A firewall referencing the rulestack, for those rules to run.

πŸ“ Module Structure

terraform-azurerm-palo-alto-local-rulestack-prefix-list/
β”œβ”€β”€ providers.tf     # required_version + the pinned azurerm. No provider block.
β”œβ”€β”€ variables.tf     # 6 variables, 9 validations
β”œβ”€β”€ main.tf          # 25 locals + the single keystone resource
β”œβ”€β”€ outputs.tf       # 36 outputs
β”œβ”€β”€ README.md        # this file
β”œβ”€β”€ SCOPE.md         # the cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "corp_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "corp-ranges"
  prefix_list  = ["10.0.0.0/8", "172.16.0.0/12"]

  description = "Corporate address space"
}

⚠️ Pick the name carefully. Changing it later does not work and does not error β€” see example 3.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
rulestack_id string terraform-azurerm-palo-alto-local-rulestack β†’ id
name string the caller. Effectively immutable
prefix_list list(string) the caller. CIDR blocks only
audit_comment, description string the caller

Emits

Output Description
id The prefix list's Resource ID β€” what a rule references
covers_every_ipv4_address The widest entry a prefix list can hold
duplicate_prefix_count Repeats, which make a list look larger than it is
renaming_requires_replacement_and_the_provider_does_not_enforce_it Constant true
deleting_this_takes_effect_immediately_unlike_a_rule Constant true

πŸ“š Example Library

1 Β· Minimal β€” one network range
module "corp_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "corp-ranges"
  prefix_list  = ["10.0.0.0/8"]
}

output "shape" {
  value = {
    count     = module.corp_ranges.prefix_count            # 1
    broadest  = module.corp_ranges.broadest_prefix_length  # 8
    wide_open = module.corp_ranges.covers_every_ipv4_address # false
  }
}

ℹ️ broadest_prefix_length is the smallest mask number, because a smaller mask covers more addresses. A /8 is broader than a /24.

2 Β· Several ranges, documented
module "corp_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "corp-ranges"
  prefix_list = [
    "10.0.0.0/8",
    "172.16.0.0/12",
    "192.168.0.0/16",
  ]

  description   = "RFC1918 corporate address space"
  audit_comment = "Baseline, raised by CHG-2001"
}

check "shared_objects_are_documented" {
  assert {
    condition     = !module.corp_ranges.is_undocumented
    error_message = "A prefix list has neither a description nor an audit comment. Named objects outlive the people who made them."
  }
}

πŸ’‘ Both fields are free-form, unvalidated and cost nothing. is_undocumented exists because a named address object with no explanation is the thing nobody later dares to delete.

3 Β· πŸ”΄ The rename trap
module "corp_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id

  # Changing this does NOT work, and does NOT error.
  name        = "corp-ranges-v2"
  prefix_list = ["10.0.0.0/8"]
}

output "read_before_renaming" {
  value = module.corp_ranges.renaming_requires_replacement_and_the_provider_does_not_enforce_it
}

πŸ”΄ Three provider facts combine into one trap. name is required and not force-new, so Terraform plans an in-place update rather than a replacement. The update path never sends the name, so Azure renames nothing. The read path sets the name from the Resource ID, which never changes. The apply therefore succeeds, changes nothing, and the same diff returns on every subsequent plan.

βœ… The fix is to replace the resource:

terraform apply -replace='module.corp_ranges.azurerm_palo_alto_local_rulestack_prefix_list.this'

That is safe here in a way it would not be for a rule: this resource's delete and create both commit, so there is no window where the rulestack is running stale configuration. Any rule referencing the old ID must be updated to the new one in the same apply.

4 Β· The widest possible list, and asserting against it
module "everything" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "everything"
  prefix_list  = ["0.0.0.0/0"]
}

check "no_wide_open_prefix_lists" {
  assert {
    condition = !module.everything.covers_every_ipv4_address
    error_message = "Prefix list '${module.everything.name}' contains 0.0.0.0/0, so every rule referencing it is unrestricted on that side."
  }
}

πŸ”΄ This is the quiet version of an any-to-any rule. A rule referencing this list looks narrow β€” it names one prefix list β€” while matching every address. The rule's own permits_any_source_to_any_destination flag will read false, because that flag sees the rule's literal CIDRs, not the contents of a referenced list. The two checks are complementary and you want both.

ℹ️ covers_every_ipv6_address is separate on purpose: a list can be narrow in one address family and open in the other.

5 Β· The literal `"any"` is rejected here
module "wrong" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "any-attempt"

  # REJECTED. A rule accepts "any"; a prefix list does not.
  # prefix_list = ["any"]

  # This is the equivalent.
  prefix_list = ["0.0.0.0/0"]
}

⚠️ Two resources in one family, two different vocabularies for the same idea. azurerm_palo_alto_local_rulestack_rule accepts cidrs = ["any"]; this resource validates each entry with IsCIDR, which rejects it. Moving a value from one to the other is where this bites.

ℹ️ The module's own validation message names both this case and the bare-IP case, because the provider's IsCIDR error names neither.

6 Β· A bare IP address needs its mask
module "jump_hosts" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "jump-hosts"

  # WRONG: prefix_list = ["10.1.0.5", "10.1.0.6"]
  prefix_list = ["10.1.0.5/32", "10.1.0.6/32"]
}

output "kind_of_list" {
  value = {
    hosts_only = module.jump_hosts.every_prefix_is_a_single_address # true
    hosts      = module.jump_hosts.single_address_prefixes
  }
}

⚠️ A list made entirely of /32s is an allow-list of individual machines, not of networks. That is a legitimate design and a maintenance liability: it goes stale as hosts are replaced, and nothing in Azure or Terraform will tell you. every_prefix_is_a_single_address names the shape so a reviewer can ask how it is kept current.

7 Β· Duplicates are accepted and reported
locals {
  # Two sources of truth merged without dedup -- a common accident.
  from_cmdb   = ["10.6.0.0/16", "172.20.0.0/14"]
  from_manual = ["10.6.0.0/16"]
}

module "merged" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "merged-ranges"
  prefix_list  = concat(local.from_cmdb, local.from_manual)
}

output "merge_quality" {
  value = {
    entries    = module.merged.prefix_count           # 3
    distinct   = module.merged.distinct_prefix_count  # 2
    duplicates = module.merged.duplicate_prefix_count # 1
  }
}

ℹ️ The provider accepts duplicates and they change nothing about what is matched, so this module reports rather than rejects β€” refusing them would reject legal input. A non-zero duplicate_prefix_count is almost always a merge accident in the caller's own configuration, and distinct(...) is the fix.

8 Β· IPv6 entries
module "dual_stack" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "dual-stack"
  prefix_list  = ["10.5.0.0/16", "2001:db8::/32"]
}

output "address_families" {
  value = {
    has_v6 = module.dual_stack.contains_ipv6_prefixes # true
    v6     = module.dual_stack.ipv6_prefixes
    v6_n   = module.dual_stack.ipv6_prefix_count      # 1
  }
}

⚠️ These pass plan-time validation because IsCIDR accepts IPv6. Whether Cloud NGFW treats an IPv6 prefix identically to an IPv4 one inside a rulestack is not something Terraform can see, so this module reports their presence and refuses nothing β€” rejecting them would refuse input the provider accepts.

9 Β· Comparing two lists for equality
module "list_a" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"
  rulestack_id = module.rulestack.id
  name         = "list-a"
  prefix_list  = ["10.0.0.0/8", "172.16.0.0/12"]
}

module "list_b" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"
  rulestack_id = module.rulestack.id
  name         = "list-b"
  prefix_list  = ["172.16.0.0/12", "10.0.0.0/8"] # same set, different order
}

output "are_they_the_same" {
  value = module.list_a.prefixes_sorted == module.list_b.prefixes_sorted # true
}

πŸ’‘ prefix_list preserves the order you supplied; prefixes_sorted exists so two lists holding the same addresses compare equal regardless of order. Useful when consolidating duplicated lists before deleting one.

10 Β· Wiring it into a rule β€” both sides
module "corp_ranges" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"
  rulestack_id = module.rulestack.id
  name         = "corp-ranges"
  prefix_list  = ["10.0.0.0/8"]
}

module "datacenter" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"
  rulestack_id = module.rulestack.id
  name         = "datacenter"
  prefix_list  = ["10.200.0.0/16"]
}

module "corp_to_datacenter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "corp-to-datacenter"
  priority     = 1000
  action       = "Allow"
  applications = ["ssl"]
  protocol     = "application-default"

  source_match      = { local_rulestack_prefix_list_ids = [module.corp_ranges.id] }
  destination_match = { local_rulestack_prefix_list_ids = [module.datacenter.id] }
}

ℹ️ A prefix list can be referenced from BOTH sides of a rule β€” local_rulestack_prefix_list_ids exists on the source and the destination. FQDN lists are destination-only.

πŸ’‘ Wire module.<x>.id, not a literal ID. That makes the dependency real, so Terraform creates the lists before the rule β€” and the rule module neither creates nor verifies the lists it references.

11 Β· One list, many rules β€” the blast radius
module "trusted" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"
  rulestack_id = module.rulestack.id
  name         = "trusted"
  prefix_list  = ["10.0.0.0/8"] # widen this and every rule below widens
}

module "rules" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"
  for_each = {
    web = { priority = 1000, apps = ["web-browsing", "ssl"] }
    dns = { priority = 1100, apps = ["dns"] }
    ssh = { priority = 1200, apps = ["ssh"] }
  }

  rulestack_id = module.rulestack.id
  name         = "allow-${each.key}"
  priority     = each.value.priority
  action       = "Allow"
  applications = each.value.apps
  protocol     = "application-default"

  source_match      = { local_rulestack_prefix_list_ids = [module.trusted.id] }
  destination_match = { cidrs = ["any"] }
}

πŸ”΄ This is the reason to review prefix-list changes as carefully as rule changes. Adding one entry to trusted widens three rules in a single apply, and none of those rules appears in the plan as changed. The rules' own breadth flags will not detect it either, because they read each rule's literal CIDRs rather than the contents of a referenced list.

⚠️ Note also that all four resources here write to one rulestack, so they serialize and pay a commit each.

12 Β· A set of lists with `for_each`
locals {
  address_groups = {
    corp       = ["10.0.0.0/8", "172.16.0.0/12"]
    datacenter = ["10.200.0.0/16"]
    jump-hosts = ["10.1.0.5/32", "10.1.0.6/32"]
    partners   = ["203.0.113.0/24"]
  }
}

module "prefix_lists" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"
  for_each = local.address_groups

  rulestack_id = module.rulestack.id
  name         = each.key
  prefix_list  = each.value

  description = "Managed by Terraform"
}

output "review" {
  value = {
    for k, m in module.prefix_lists : k => {
      entries    = m.prefix_count
      broadest   = m.broadest_prefix_length
      wide_open  = m.covers_every_ipv4_address
      hosts_only = m.every_prefix_is_a_single_address
    }
  }
}

⚠️ These do not apply in parallel. Every write locks the parent rulestack, so four lists are created one after another β€” and each one commits the rulestack, so the commit cost is paid four times.

πŸ’‘ The for_each keys are the names, so they must satisfy the Palo Alto name rule: alphanumerics and dashes only. jump-hosts works; jump_hosts would not.

13 Β· Timeouts, and which one is different
module "big_list" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "big-list"
  prefix_list  = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]

  timeouts = {
    create = "45m"
    update = "45m"
    delete = "45m"
    read   = "10m"
  }
}

ℹ️ All four are honored β€” the provider declares each on its own CRUD function in the typed SDK, so none is inert. That is worth stating, because two resources elsewhere in this library declare an update timeout the provider never reads.

πŸ’‘ The provider's own defaults are 30 minutes for create, update and delete but only 5 minutes for read β€” read is a single GET, while the other three wait for a rulestack commit to finish. Raise read only if you are seeing read timeouts.

⚠️ Terraform silently discards object keys this type does not declare, so a misspelled key vanishes with no error. There are exactly four.

14 Β· Importing an existing prefix list
import {
  to = module.corp_ranges.azurerm_palo_alto_local_rulestack_prefix_list.this
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-netsec-eastus/providers/PaloAltoNetworks.Cloudngfw/localRulestacks/rs-edge-eastus/prefixlists/corp-ranges"
}

module "corp_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "corp-ranges" # must match the ID's last segment exactly
  prefix_list  = ["10.0.0.0/8"]
}

⚠️ The name must match the last segment of the ID. If it does not, you get the rename trap from example 3 immediately after importing: a permanent diff that never applies.

ℹ️ Creating over an existing prefix list is refused by default β€” the provider checks for one first. That check is disabled by the caller's features block flag for skipping import checks, which converts the refusal into a silent overwrite of a live address object.

15 Β· πŸ—οΈ 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-netsec-eastus"
  location = "eastus"
}

module "rulestack" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack.git?ref=v1.0.0"

  name                = "rs-edge-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location

  security_services = {
    vulnerability_profile = "BestPractice"
    anti_spyware_profile  = "BestPractice"
    anti_virus_profile    = "BestPractice"
    url_filtering_profile = "BestPractice"
    file_blocking_profile = "BestPractice"
    dns_subscription      = "BestPractice"
  }
}

module "corp_ranges" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-prefix-list.git?ref=v1.0.0"

  rulestack_id  = module.rulestack.id
  name          = "corp-ranges"
  prefix_list   = ["10.20.0.0/16", "10.21.0.0/16"]
  description   = "Spoke networks permitted outbound"
  audit_comment = "Baseline, raised by CHG-2001"
}

module "allow_egress" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "allow-egress"
  priority     = 1000
  action       = "Allow"
  applications = ["web-browsing", "ssl", "dns"]
  protocol     = "application-default"

  source_match      = { local_rulestack_prefix_list_ids = [module.corp_ranges.id] }
  destination_match = { cidrs = ["any"] }
}

module "deny_everything_else" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "deny-all"
  priority     = 999999
  action       = "DenySilent"
  applications = ["any"]
  protocol     = "application-default"

  source_match      = { cidrs = ["any"] }
  destination_match = { cidrs = ["any"] }
}

check "the_shared_list_is_not_wide_open" {
  assert {
    condition     = !module.corp_ranges.covers_every_ipv4_address
    error_message = "The prefix list every egress rule references covers all of IPv4."
  }
}

output "composition" {
  value = {
    rulestack   = module.rulestack.id
    engines_on  = module.rulestack.configured_security_engines
    prefix_list = module.corp_ranges.id
    rules       = [module.allow_egress.id, module.deny_everything_else.id]

    # All four modules agree on the rulestack they targeted.
    all_on_one_rulestack = length(distinct([
      module.rulestack.rulestack_path_within_the_subscription,
      module.corp_ranges.rulestack_path_within_the_subscription,
      module.allow_egress.rulestack_path_within_the_subscription,
      module.deny_everything_else.rulestack_path_within_the_subscription,
    ])) == 1

    # The rule's create commits, so this composition is not left staged.
    committed_by_the_rules = module.allow_egress.creating_a_rule_commits_the_whole_rulestack

    # Two withdrawal paths, two behaviors -- worth carrying into the runbook.
    removing_a_prefix_list_is_immediate = module.corp_ranges.deleting_this_takes_effect_immediately_unlike_a_rule
    removing_a_rule_is_not              = module.allow_egress.deleting_a_rule_does_not_commit
  }
}

πŸ”’ Four modules, and the composition makes the family's central asymmetry legible. Deleting the prefix list commits and takes effect at once; deleting the rule does not and leaves the firewall enforcing it. Both facts are emitted rather than described, so a runbook can read them instead of remembering them.

⚠️ The egress rule names one prefix list and one literal "any" destination. Widening corp_ranges widens that rule with no change to the rule itself β€” which is what the check block guards.

ℹ️ all_on_one_rulestack compares the family's shared rulestack_path_within_the_subscription output across all four modules β€” a subscription-independent form, so it works the same in every environment.


πŸ“₯ Inputs

Group Variables
Placement rulestack_id (force-new, the only one)
Identity name (effectively immutable β€” see Architecture Notes)
Contents prefix_list (at least one CIDR)
Documentation audit_comment, description
Tail timeouts

ℹ️ There is no tags variable, because the resource has none. Within this family, only the rule resource exposes tags; the rulestack and this prefix list do not. Tag the resource group for anything that must cover all three.

Full schemas
Variable Type Default Notes
rulestack_id string β€” Force-new. Anchored at localRulestacks/<name>
name string β€” ^[a-zA-Z0-9-]{1,128}$, no leading or trailing dash. Cannot be changed
prefix_list list(string) β€” At least one entry; every entry a valid CIDR
audit_comment string null Free-form; not whitespace-only
description string null Free-form; not whitespace-only
timeouts object {} All four honored. Read defaults to 5m, the rest to 30m

9 validations, listed rather than totaled. Two on rulestack_id (non-blank, and an anchored rulestack ID so a child ID is not accepted). Two on name (the character pattern, and the leading/trailing dash rule the provider raises separately). Two on prefix_list (at least one entry, and every entry a valid CIDR β€” with a message naming the bare-IP and "any" cases the provider's own error does not). One each on audit_comment and description (not whitespace-only). One on timeouts (duration format).

Three things are deliberately NOT validated. Duplicate entries, because the provider accepts them and they change nothing β€” reported instead. IPv6 entries, because IsCIDR accepts them and the service's treatment is not visible from Terraform. And whether a referenced address space is correct, which no module can know.

And one is validated only as a better explanation. The provider already enforces IsCIDR and a minimum of one entry; this module restates both β€” not with a stricter rule, but with an error message that names the two rejections a caller actually hits.


🧾 Outputs

37 outputs: 6 passthrough, 20 derived, 11 constant.

Output Description
id The prefix list's Resource ID β€” what a rule references
name, rulestack_id, prefix_list, audit_comment, description The arguments as applied
rulestack_name, resource_group_name, rulestack_path_within_the_subscription Parsed from the parent ID
prefix_count, distinct_prefix_count, duplicate_prefix_count How many entries, and how many are repeats
prefixes_sorted Sorted, so two lists with the same addresses compare equal
covers_every_ipv4_address Contains 0.0.0.0/0 β€” the widest entry possible
covers_every_ipv6_address Contains ::/0
broadest_prefix_length, narrowest_prefix_length Smallest and largest mask lengths; null if none parsed
single_address_prefixes, single_address_prefix_count The /32 and /128 entries
every_prefix_is_a_single_address The list is a host allow-list
contains_ipv6_prefixes, ipv6_prefixes, ipv6_prefix_count IPv6 presence and contents
has_audit_comment, has_description, is_undocumented Documentation state
renaming_requires_replacement_and_the_provider_does_not_enforce_it Constant true
every_write_locks_and_commits_the_parent_rulestack Constant true
deleting_this_takes_effect_immediately_unlike_a_rule Constant true
the_provider_borrows_the_rules_name_validator_for_this_resource Constant true
the_literal_any_is_not_accepted_here Constant true
a_bare_ip_address_is_rejected Constant true
the_existence_check_can_be_disabled_by_a_provider_feature Constant true
this_resource_has_no_tags Constant true
rules_reference_this_by_id_not_by_name Constant true
nothing_here_reports_whether_any_rule_uses_this_list Constant true
editing_name_does_not_repoint_this_resource Constant true. name is a segment of the Terraform ID yet is not force-new, so editing it updates the OLD prefix list in place and the read then rewrites name back β€” the plan never converges

ℹ️ Nothing here is sensitive. A prefix list holds network addresses, which are configuration rather than credentials.


🧠 Architecture Notes

A prefix list is how a rulestack stops repeating itself, and that is also its risk. One named object, referenced by many rules, on either side of the match. Change it once and every referencing rule changes with it β€” in a single apply, with no rule showing as modified in the plan. The rule module's own breadth flags will not catch it either, because they read each rule's literal CIDRs rather than the contents of a referenced list. covers_every_ipv4_address on this side and permits_any_source_to_any_destination on the rule side are complementary, and a review wants both.

The rename trap is the module's headline, and it is built from three separate provider facts. name is required and not marked force-new, so Terraform plans an in-place update rather than a replacement. The update path handles prefix_list, audit_comment and description and never sends the name, so Azure renames nothing. The read path then sets the name from the Resource ID, which never changes. Each fact is defensible on its own; together they produce a change that plans, applies without error, and reappears on every subsequent plan forever. Nothing in the provider or its documentation says so. This module cannot fix it β€” refusing a rename would invent a constraint the provider does not have β€” so it names it in the variable's description, in a constant output, in Troubleshooting, and in an example, and it gives the -replace command that does work.

All three writes commit, and that is a genuine contrast with the sibling rule. Create, update and delete each lock the parent rulestack and then commit it; reads do neither. So deleting a prefix list takes effect immediately, where deleting a rule leaves the running firewall enforcing it until something else commits. The contrast is emitted as its own output because a reader arriving from the rule module would otherwise carry the sharper caveat across the family, where it does not apply. The cost of the same fact is that a for_each over several lists serializes and pays a commit each.

The vocabulary differs from a rule's, in one specific and confusing way. A rule's source and destination CIDR lists accept the literal string "any". A prefix list's entries are validated with IsCIDR, which rejects it β€” the equivalent is the explicit 0.0.0.0/0. Similarly, a bare IP address is refused where 10.0.0.1/32 is accepted. Both are restated by this module's own validation, not to be stricter than the provider but to explain the provider's own rejection, whose message names neither case.

What the module reports rather than rejects, and why. Duplicate entries are accepted by the provider and change nothing about what is matched, so refusing them would reject legal input β€” duplicate_prefix_count reports them instead, and a non-zero value is nearly always a merge accident. IPv6 CIDRs pass IsCIDR, so they reach Azure; whether Cloud NGFW treats them identically to IPv4 inside a rulestack is not visible from Terraform, so their presence is surfaced and nothing is refused.

The name validator is the rule's, not its own. The provider validates this resource's name with LocalRuleStackRuleName, and that function carries a TODO - Check this comment in the provider's source. The sibling FQDN list has a validator of its own. The rule that actually binds is the shared Palo Alto one β€” alphanumerics and dashes, 1 to 128 characters, no leading or trailing dash β€” which matters when a for_each key with an underscore is rejected.

And the module can see nothing about whether this list is used. A prefix list filters nothing on its own, costs nothing, and produces no signal when the last rule referencing it is deleted. Neither Terraform nor Azure will report an orphan. nothing_here_reports_whether_any_rule_uses_this_list says so rather than leaving the silence to be interpreted.


🧱 Design Principles

Concern This module's default The caller's opt-out
List breadth No default. 0.0.0.0/0 must be typed, and is reported β€”
The literal "any" Rejected, with a message naming the 0.0.0.0/0 equivalent β€”
A bare IP Rejected, with a message naming the /32 fix β€”
Duplicates Reported, not rejected β€” the provider accepts them β€”
IPv6 entries Reported, not rejected β€” the provider accepts them β€”
Documentation Not required, but is_undocumented is emitted leave both unset
Renaming Reported as impossible. No module can change it use -replace
MinItems and IsCIDR Restated only as a better error message, never as a stricter rule β€”

πŸ”’ There is no empty call to make safe here, and that is worth saying plainly. rulestack_id, name and at least one CIDR are all required β€” a prefix list must say what it contains. So this module's secure-by-default work is not in choosing defaults but in making breadth visible: every entry's mask length, whether the set covers everything, whether it is really a host allow-list, and whether the same address appears twice. Those are what a check block can assert on.

ℹ️ Note also what a prefix list is not: it grants no access by itself. It becomes an allow or a deny only through the rule that references it, which is where the action lives.


πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 β€” never a branch. This module is plan-only in this library; a human applies from CI.

πŸ”΄ To rename, replace β€” terraform apply -replace='module.<x>.azurerm_palo_alto_local_rulestack_prefix_list.this'. Editing name in place does not work and does not error.

βœ… To withdraw, just delete it. The delete commits, so it takes effect at once. Update any rule referencing the ID in the same apply.


πŸ§ͺ Testing

Covered by What it proves
terraform validate The resource and every expression parse and type-check against the pinned provider schema
terraform fmt -check Canonical formatting
terraform console with a root-module fixture Variable validations actually fire. All 9 rules were driven by deliberately bad inputs, and all 25 locals were driven to more than one value by good ones
terraform plan (needs credentials) Whether the rulestack exists, and whether a prefix list of that name is already there
Nothing offline Whether the addresses are the right addresses, or whether any rule references this list
Nothing at all, until you try it That a rename will not apply β€” which is why it is documented here

⚠️ Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof a rule is missing. Fix the first failure and re-run.


πŸ’¬ Example Output

id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-netsec-eastus/providers/PaloAltoNetworks.Cloudngfw/localRulestacks/rs-edge-eastus/prefixlists/corp-ranges"
name = "corp-ranges"
prefix_list = [
  "10.20.0.0/16",
  "10.21.0.0/16",
]
rulestack_name = "rs-edge-eastus"
resource_group_name = "rg-netsec-eastus"
rulestack_path_within_the_subscription = "/resourceGroups/rg-netsec-eastus/providers/PaloAltoNetworks.Cloudngfw/localRulestacks/rs-edge-eastus"
prefix_count = 2
distinct_prefix_count = 2
duplicate_prefix_count = 0
prefixes_sorted = [
  "10.20.0.0/16",
  "10.21.0.0/16",
]
covers_every_ipv4_address = false
covers_every_ipv6_address = false
broadest_prefix_length = 16
narrowest_prefix_length = 16
single_address_prefix_count = 0
every_prefix_is_a_single_address = false
contains_ipv6_prefixes = false
is_undocumented = false
renaming_requires_replacement_and_the_provider_does_not_enforce_it = true
deleting_this_takes_effect_immediately_unlike_a_rule = true

πŸ” Troubleshooting

Symptom Cause Fix
A name change plans every time and never sticks Not force-new, never sent on update, restored from the ID on read terraform apply -replace=.... Editing in place cannot work
expected ... to contain a valid CIDR A bare IP address Add the mask: 10.0.0.1/32
The same, on the value any A rule accepts "any"; a prefix list does not Use 0.0.0.0/0
prefix_list must contain at least one CIDR block An empty list The provider enforces a minimum of one
name may only contain alphanumerics and dashes An underscore, often from a for_each key Rename the key, or replace(each.key, "_", "-")
rulestack_id must be a local rulestack Resource ID Passed a child ID or a truncated path It must end at /localRulestacks/<name>
A rule does not match traffic you expected The rule references the list by ID; check you wired the right one Compare rulestack_path_within_the_subscription across both modules
Widening one list changed several rules That is what a shared prefix list does Assert on covers_every_ipv4_address; review shared lists like rules
Lists apply one at a time, slowly Every write locks and commits the parent rulestack Expected. Each list pays its own commit
A resource with this ID already exists A prefix list of that name is already in the rulestack Import it, or pick another name
An overwrite happened with no error The provider features flag for skipping import checks is on That flag turns the refusal into a silent overwrite
A deleted prefix list still appears in a rule The rule was not updated to stop referencing it Update the rule; the list's own deletion did commit
An IPv6 entry applied but seems inert IsCIDR accepts IPv6; the service's handling is not visible here Verify on the firewall side

πŸ”— Related Docs

  • azurerm_palo_alto_local_rulestack_prefix_list β€” the provider resource
  • What is Cloud NGFW by Palo Alto Networks? β€” the Marketplace subscription and the PaloAltoNetworks.Cloudngfw resource provider
  • Cloud NGFW by Palo Alto Networks FAQ β€” what an Azure Rulestack can configure
  • az palo-alto cloudngfw local-rulestack β€” the commit operation every write here performs
  • terraform-azurerm-palo-alto-local-rulestack β€” the parent, and where the threat-prevention engines live
  • terraform-azurerm-palo-alto-local-rulestack-rule β€” the rules that reference this list, on either side of the match
  • terraform-azurerm-palo-alto-local-rulestack-fqdn-list β€” the domain equivalent, destination-only, whose entries the provider does not validate
  • terraform-azurerm-palo-alto-local-rulestack-certificate β€” the rulestack's certificate object, which unlike this one does not commit on delete
  • terraform-azurerm-resource-group β€” the resource group
  • This module's SCOPE.md

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