Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Palo Alto Local Rulestack Rule Terraform Module

Creates a security rule in a Cloud NGFW local rulestack — the traffic policy a rulestack otherwise lacks. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🛡️ Creates one azurerm_palo_alto_local_rulestack_rule — where a Cloud NGFW rulestack's actual traffic policy lives.
  • 🔴 Reports the asymmetry that matters most: creating or updating a rule commits the whole rulestack; deleting one does not.
  • 🎯 Emits the flags a firewall review needs — permits_any_source_to_any_destination, negates_a_match, logging_is_disabled — rather than leaving them to be read out of the configuration.
  • 📊 Defaults logging_enabled to true where the provider defaults it to false. The one deliberate divergence, and the reason is in the variable.
  • 🧭 Enforces the certificate-to-decryption pairing the provider documents and does not check.

💡 Why it matters: a rulestack with no rules inspects nothing, so this is the resource that makes the parent useful — and it is where a firewall's mistakes live. Priority is the identity and is force-new, negation inverts what the configuration appears to say, and a deleted rule keeps being enforced until something commits. Nineteen derived outputs exist to put those in a plan.


❤️ 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. Every write here locks that rulestack, so a for_each over rules serializes"]
  IN_IDENTITY["priority: 1 to 1000000, FORCE-NEW, and the provider's documented PRIMARY IDENTIFIER for a rule. Leave gaps between priorities. The name is NOT force-new and still cannot be edited in place -- neither identity field can change without recreating the rule"]
  IN_ACTION["action: Allow, DenySilent, DenyResetServer or DenyResetBoth. No default -- a rule must say what it does, and three of the four are denies"]
  IN_MATCH["source_match and destination_match: each REQUIRED, each needing at least one of its dimensions. Renamed from the provider's source because source is a RESERVED Terraform variable name"]
  IN_SCOPE["applications, protocol or protocol_ports, and an optional category block. Exactly one of the two protocol arguments is required and the provider enforces that"]
  IN_DECRYPT["decryption_rule_type with an optional inspection_certificate_id. The certificate is documented as valid ONLY with inbound inspection, and the provider does not enforce that -- so this module does"]

  LOGDEFAULT["logging_enabled DEFAULTS TO TRUE HERE and to FALSE in the provider. The one deliberate divergence: a rule that does not log is a control nobody can audit, and logging is not a paid subscription"]

  THIS["azurerm_palo_alto_local_rulestack_rule.this"]

  COMMIT["CREATE and UPDATE each COMMIT THE WHOLE RULESTACK -- which is what finally activates the parent's security engines. DELETE does NOT commit, so a removed rule stays in force until something else does"]
  RENAME["THE RENAME TRAP, shared with the prefix list and the certificate: the update assigns RuleName nowhere and the read restores it from the API, so a name change applies, changes nothing, and returns every plan"]
  LISTS["prefix lists, FQDN lists and certificates are SEPARATE resources in the same rulestack, referenced here by id. This module neither creates nor verifies them"]

  OUT_ID["id, name, rulestack_id, priority, action, applications, enabled, logging_enabled and tags as applied"]
  OUT_PARENT["rulestack_name, resource_group_name and rulestack_path_within_the_subscription, parsed from the parent id"]
  OUT_WIDTH["source_matches_any_address, destination_matches_any_address, matches_any_application, and the two match-dimension lists"]
  OUT_RISK["permits_traffic, permits_any_source_to_any_destination, permits_any_application_from_any_source and negates_a_match -- the flags to assert on"]
  OUT_POSTURE["effective_decryption_rule_type, performs_decryption, inbound_decryption_without_a_certificate, logging_is_disabled, rule_is_disabled, protocol_mode, matches_url_categories"]
  OUT_CONST["twelve constants, including the rename trap, that deleting a rule does not commit, and that tags exist here but not on the rulestack"]

  IN_PARENT --> THIS
  IN_IDENTITY --> THIS
  IN_ACTION --> THIS
  IN_MATCH --> THIS
  IN_SCOPE --> THIS
  IN_DECRYPT --> THIS
  LOGDEFAULT -.->|"the only divergence from the provider"| THIS

  THIS --> COMMIT
  THIS -.->|"reported, not preventable"| RENAME
  LISTS -.->|"referenced by id"| THIS
  THIS --> OUT_ID
  THIS --> OUT_PARENT
  THIS --> OUT_WIDTH
  THIS --> OUT_RISK
  THIS --> OUT_POSTURE
  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_WIDTH,OUT_RISK,OUT_POSTURE,OUT_CONST me
  class IN_PARENT,IN_IDENTITY,IN_ACTION,IN_MATCH,IN_SCOPE,IN_DECRYPT,LOGDEFAULT,COMMIT,RENAME,LISTS sibling
Loading

Resource inventory

Resource Count Notes
azurerm_palo_alto_local_rulestack_rule 1 (this) Two force-new arguments: rulestack_id and priority

✅ 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/localRules

Schema notes that bite

  • 🔴 Create commits, update commits, DELETE DOES NOT. Removing a rule with Terraform deletes it from the rulestack's staged configuration and leaves the running firewall enforcing it until something else commits.
  • 🔴 The name cannot be changed in place, and nothing enforces that. It is not force-new, the update path assigns it nowhere, and the read restores it from the API response — so a rename applies cleanly, changes nothing, and returns on every later plan. Use terraform apply -replace=....
  • 🔴 priority is the rule's identity and is force-new. The provider: "This is the primary identifier of a rule, as such it is not possible to change the Priority of a rule once created." Combined with the above, a re-prioritize is a destroy-and-create whose destroy does not commit.
  • 🔴 source is a RESERVED Terraform variable name, so this module's inputs are source_match and destination_match. The provider's blocks are still source and destination.
  • 🔴 logging_enabled defaults to false in the provider. This module defaults it to true.
  • ⚠️ inspection_certificate_id is documented as valid only with SSLInboundInspection, and the provider does not enforce it. This module does.
  • ⚠️ Exactly one of protocol and protocol_ports is required — the provider carries an ExactlyOneOf, so it is not restated here. In v4 the old implicit default is gone: write protocol = "application-default" explicitly to keep it.
  • ⚠️ source and destination are each required and each need at least one dimension. There is no implicit "any" — matching everything is said with cidrs = ["any"], an explicit literal the provider accepts.
  • ⚠️ Every write locks the parent rulestack, so a for_each over rules serializes.
  • ⚠️ Prefix lists, FQDN lists and certificates are separate resources in the same rulestack, referenced here by ID. This module neither creates nor verifies them.
  • ⚠️ tags are available here and NOT on the parent rulestack — an asymmetry inside one family.
  • ℹ️ The name validator carries a TODO - Check this comment in the provider's own source. It binds regardless.

🔑 Required Azure RBAC Roles / Permissions

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

🔒 This is the permission that decides what traffic is allowed. Whoever can write rules here can permit any source to any destination on any application, and can turn a rule's logging off so the change leaves no trace in the firewall's own logs. It belongs in the same review as a network security group or route table — arguably a stricter one, because a rule can also negate its own match.

⚠️ The commit permission is not optional here. Unlike the parent rulestack, whose create does not commit, this resource's create and update both do — so an identity without commit/action will fail after writing the rule.


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.
  • Any prefix lists, FQDN lists or certificates this rule references, created first in the same rulestack.
  • A firewall referencing the rulestack, for the rule to affect traffic at all.
  • Knowledge of the App-ID names you intend to match. The catalog is Palo Alto's and is not published as an enum, so nothing here validates them.

📁 Module Structure

terraform-azurerm-palo-alto-local-rulestack-rule/
├── providers.tf     # required_version + the pinned azurerm. No provider block.
├── variables.tf     # 20 variables, 22 validations
├── main.tf          # 24 locals + the single keystone resource
├── outputs.tf       # 40 outputs
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "allow_web" {
  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-web-out"
  priority     = 1000
  action       = "Allow"
  applications = ["web-browsing", "ssl"]
  protocol     = "application-default"

  source_match      = { cidrs = ["10.0.0.0/8"] }
  destination_match = { cidrs = ["any"] }
}

ℹ️ priority = 1000 rather than 1 on purpose — leave gaps, because priority is force-new and inserting a rule between two adjacent numbers means recreating one of them.


🔌 Cross-Module Contract

Consumes

Input Type Source
rulestack_id string terraform-azurerm-palo-alto-local-rulestack → id
name, priority, action, applications — the caller
source_match, destination_match object the caller; prefix/FQDN list IDs from sibling resources
inspection_certificate_id string a certificate in the same rulestack

Emits

Output Description
id The rule's Resource ID (first)
permits_any_source_to_any_destination The widest rule this resource can express
logging_is_disabled Logging turned off deliberately
negates_a_match Either side's match is inverted
deleting_a_rule_does_not_commit Constant true

📚 Example Library

1 · Minimal — a narrow allow
module "allow_web" {
  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-web-out"
  priority     = 1000
  action       = "Allow"
  applications = ["web-browsing", "ssl"]
  protocol     = "application-default"

  source_match      = { cidrs = ["10.0.0.0/8"] }
  destination_match = { cidrs = ["any"] }
}

output "posture" {
  value = {
    permits       = module.allow_web.permits_traffic                        # true
    any_to_any    = module.allow_web.permits_any_source_to_any_destination  # false
    dest_is_any   = module.allow_web.destination_matches_any_address        # true
    logged        = module.allow_web.logging_enabled                        # true, by this module's default
  }
}

ℹ️ A named source and an any destination is the ordinary shape of an egress allow. permits_any_source_to_any_destination is false because the source is narrow — that flag is specifically about both sides being open.

2 · The widest rule, and asserting against it
module "allow_everything" {
  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-any-any"
  priority     = 1
  action       = "Allow"
  applications = ["any"]
  protocol     = "application-default"

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

check "no_any_to_any_allow_rules" {
  assert {
    condition     = !module.allow_everything.permits_any_source_to_any_destination
    error_message = "A rule allows any source to any destination. Narrow one side, or approve the exception explicitly."
  }
}

🔴 This is the widest rule the resource can express, and at priority = 1 it is evaluated first — so it makes every rule below it irrelevant. The flag is computed through the derived any-address facts rather than by matching literals, so it holds however the caller wrote them.

💡 permits_any_application_from_any_source is the companion: a rule can be narrow on addresses and wide on applications, and the two read differently in a review.

3 · The three deny actions
module "deny_examples" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"
  for_each = {
    silent       = { action = "DenySilent", priority = 2000 }
    reset_server = { action = "DenyResetServer", priority = 2100 }
    reset_both   = { action = "DenyResetBoth", priority = 2200 }
  }

  rulestack_id = module.rulestack.id
  name         = "deny-${each.key}"
  priority     = each.value.priority
  action       = each.value.action
  applications = ["any"]
  protocol     = "application-default"

  source_match      = { countries = ["CN", "RU"] }
  destination_match = { cidrs = ["any"] }
}

ℹ️ Three of the four actions are denies, and they differ in what the client learns: DenySilent drops with no response, DenyResetServer resets the server side, DenyResetBoth resets both. permits_traffic is false for all three, which is why it is the flag to assert on rather than enumerating actions.

4 · Priority is the identity
module "rule" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id

  # ALSO not editable in place -- see the callout below.
  name = "allow-web-out-v2"

  # NOT editable. Changing this destroys and recreates the rule.
  priority = 1000

  action       = "Allow"
  applications = ["ssl"]
  protocol     = "application-default"

  source_match      = { cidrs = ["10.0.0.0/8"] }
  destination_match = { cidrs = ["any"] }
}

output "identity" {
  value = module.rule.the_priority_is_the_rules_identity_and_is_force_new
}

🔴 The provider is explicit about the priority: "This is the primary identifier of a rule, as such it is not possible to change the Priority of a rule once created." So the number is the identity, which is the reverse of most resources.

🔴 And the NAME cannot be changed in place either, though nothing says so. It is not marked force-new, so a change plans as an in-place update. But the update path assigns RuleName nowhere — it has a branch for all sixteen other arguments and none for the name — and it sends back the record it just read, so Azure keeps the old value. The read then restores the name from the API response. The diff therefore applies cleanly, changes nothing, and returns on every later plan. Neither of this resource's two identity-ish fields can be edited in place, for two entirely different reasons.

⚠️ And a re-prioritize is worse than an ordinary replacement, because the destroy half does not commit. See example 5.

5 · The commit asymmetry
output "the_asymmetry" {
  value = {
    create_commits = module.rule.creating_a_rule_commits_the_whole_rulestack   # true
    delete_commits = !module.rule.deleting_a_rule_does_not_commit              # false
  }
}

🔴 Create commits. Update commits. Delete does not. Removing a rule with terraform destroy or by deleting its module block takes it out of the rulestack's staged configuration and leaves the running firewall enforcing it. Revoking an Allow is therefore not complete when the apply finishes.

✅ The remedy, in order of preference: disable the rule instead of deleting it (enabled = false is an update, so it commits and takes effect immediately); or follow the delete with a commit — either az palo-alto cloudngfw local-rulestack commit, or any other change in the same rulestack that triggers an update.

✅ And the same asymmetry resolves the parent's caveat. A rulestack created on its own is never committed; a rulestack created together with at least one rule is, because the rule's create commits it. That is creating_a_rule_commits_the_whole_rulestack.

6 · Logging is on by default here
module "quiet_rule" {
  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-health-probes"
  priority     = 3000
  action       = "Allow"
  applications = ["any"]
  protocol     = "TCP:443"

  source_match      = { cidrs = ["168.63.129.16/32"] }
  destination_match = { cidrs = ["any"] }

  # Deliberate: probe traffic is high-volume and low-value to log.
  logging_enabled = false
}

check "rules_are_logged_unless_excepted" {
  assert {
    condition     = !module.quiet_rule.logging_is_disabled
    error_message = "A rule has logging disabled. That is a control nobody can audit -- confirm the exception is intended."
  }
}

📊 This module defaults logging_enabled to true; the provider defaults it to false. It is the only divergence in the module, and the reasoning is narrow: a firewall rule that does not log is a control nobody can audit, investigate or evidence — and unlike the parent rulestack's threat-prevention engines, logging is not a paid Palo Alto subscription, so this suite's exposure-not-spend boundary does not exempt it.

💰 It does generate log volume, which costs money at ingestion. Turning it off for a high-volume, low-value rule is a legitimate trade — the check block above is how you make it a reviewed one.

7 · Matching on geography and threat feeds
module "deny_geo" {
  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-high-risk-geo"
  priority     = 500
  action       = "DenyResetBoth"
  applications = ["any"]
  protocol     = "application-default"

  source_match = {
    countries = ["CN", "RU", "KP"]
    feeds     = ["panw-known-malicious-ip-list"]
  }

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

output "how_it_matches" {
  value = module.deny_geo.source_match_dimensions # ["countries", "feeds"]
}

ℹ️ Four ways to describe a source, and at least one is required — the provider enforces that with its own AtLeastOneOf, so this module does not restate it. source_match_dimensions reports which were used, sorted, so a review can see at a glance whether a rule matches on addresses, geography, a feed, or a managed list.

⚠️ Country codes must be uppercase two-letter ISO 3166 values; "us" is rejected here with that explanation.

8 · Managed lists are separate resources
# Prefix lists ARE a module. FQDN lists are not yet, so that one is shown as a
# plain resource -- which also exposes the arguments a future module will render.
module "corp" {
  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"]
}

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

  rulestack_id                 = module.rulestack.id
  name                         = "allowed-domains"
  fully_qualified_domain_names = ["example.com", "contoso.com"]
}

module "allow_corp_to_allowed" {
  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-corp-to-allowed"
  priority     = 4000
  action       = "Allow"
  applications = ["ssl", "web-browsing"]
  protocol     = "application-default"

  source_match      = { local_rulestack_prefix_list_ids = [module.corp.id] }
  destination_match = { local_rulestack_fqdn_list_ids = [module.allowed.id] }
}

ℹ️ Lists are referenced by ID, not by name, and they must belong to the same rulestack. This module neither creates nor verifies them — the_rulestack_owns_the_prefix_and_fqdn_lists_this_rule_references says so.

💡 Wiring the references rather than literal IDs makes the dependency real, so Terraform creates the lists before the rule that uses them.

⚠️ A referenced list can be wider than the rule looks. A prefix list containing 0.0.0.0/0 makes this rule unrestricted on that side, while this module's own permits_any_source_to_any_destination still reads false — it sees the rule's literal CIDRs, not the contents of a list. Assert on the prefix-list module's covers_every_ipv4_address as well.

9 · Negation inverts what the configuration says
module "allow_except_guest" {
  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-except-guest"
  priority     = 5000
  action       = "Allow"
  applications = ["ssl"]
  protocol     = "application-default"

  # Reads as "guest network"; MEANS "everything except the guest network".
  source_match  = { cidrs = ["10.99.0.0/16"] }
  negate_source = true

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

output "read_this_twice" {
  value = module.allow_except_guest.negates_a_match # true
}

⚠️ Negation is where firewall rules are misread. The configuration above lists the guest network and permits everything but it. Combined with Allow, a negated source is close to the opposite of what the block appears to say.

💡 negates_a_match is emitted separately from the match dimensions precisely so a reviewer sees the inversion without having to notice a boolean buried among twenty arguments.

10 · Inbound decryption with a certificate
resource "azurerm_palo_alto_local_rulestack_certificate" "inbound" {
  name         = "inbound-inspection"
  rulestack_id = module.rulestack.id
  self_signed  = true
}

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

  rulestack_id = module.rulestack.id
  name         = "inspect-inbound-https"
  priority     = 600
  action       = "Allow"
  applications = ["ssl"]
  protocol     = "TCP:443"

  source_match      = { cidrs = ["any"] }
  destination_match = { cidrs = ["10.1.0.0/16"] }

  decryption_rule_type      = "SSLInboundInspection"
  inspection_certificate_id = azurerm_palo_alto_local_rulestack_certificate.inbound.id
}

output "decryption" {
  value = {
    type           = module.inspect_inbound.effective_decryption_rule_type # "SSLInboundInspection"
    decrypts       = module.inspect_inbound.performs_decryption            # true
    missing_cert   = module.inspect_inbound.inbound_decryption_without_a_certificate # false
  }
}

⚠️ Decryption belongs in a privacy review as well as a security one. performs_decryption is emitted because the firewall seeing plaintext that was encrypted in transit is a fact a data-protection owner may need to know about, not only a performance setting.

ℹ️ Outbound decryption (SSLOutboundInspection) uses the rulestack's own outbound trust and untrust certificate associations instead of this argument.

11 · The pairing this module enforces, and the one it reports
module "bad_pairing" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-palo-alto-local-rulestack-rule.git?ref=v1.0.0"

  rulestack_id = module.rulestack.id
  name         = "mismatched"
  priority     = 700
  action       = "Allow"
  applications = ["ssl"]
  protocol     = "TCP:443"

  source_match      = { cidrs = ["any"] }
  destination_match = { cidrs = ["10.1.0.0/16"] }

  # REJECTED: a certificate with no inbound inspection.
  # inspection_certificate_id = azurerm_palo_alto_local_rulestack_certificate.inbound.id

  # REPORTED, not rejected: inbound inspection with no certificate.
  decryption_rule_type = "SSLInboundInspection"
}

output "reported_not_refused" {
  value = module.bad_pairing.inbound_decryption_without_a_certificate # true
}

🔴 One direction is enforced and the other is not, and the difference is what the provider documents. It says inspection_certificate_id is "only valid when decryption_rule_type is set to SSLInboundInspection" — so this module rejects the certificate without the type. It does not say a certificate is required for inbound inspection, so the reverse is a reported flag rather than a refusal.

ℹ️ The enforced check is carried by inspection_certificate_id because a validation must reference its own variable — which is why its error message names decryption_rule_type too.

12 · `protocol` versus `protocol_ports`
module "single_port" {
  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-https"
  priority          = 8000
  action            = "Allow"
  applications      = ["ssl"]
  source_match      = { cidrs = ["10.0.0.0/8"] }
  destination_match = { cidrs = ["any"] }

  protocol = "TCP:443" # or "application-default"
}

module "several_ports" {
  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-web-ports"
  priority          = 8100
  action            = "Allow"
  applications      = ["web-browsing"]
  source_match      = { cidrs = ["10.0.0.0/8"] }
  destination_match = { cidrs = ["any"] }

  protocol_ports = ["TCP:80", "TCP:8080"]
}

output "modes" {
  value = {
    single = module.single_port.protocol_mode   # "single_protocol_port"
    plural = module.several_ports.protocol_mode # "protocol_ports"
  }
}

ℹ️ Exactly one of the two is required, and the provider enforces it with its own ExactlyOneOf — so this module does not restate it. protocol_mode reports which was taken, including "application_default" for the literal.

⚠️ A v4 migration note: the provider documents that the implicit default is gone — "You need to explicitly specify protocol="application-default" to keep the current default". A configuration written against v3 that relied on it must now say it.

13 · URL category matching
module "block_categories" {
  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-url-categories"
  priority     = 900
  action       = "DenySilent"
  applications = ["web-browsing", "ssl"]
  protocol     = "application-default"

  source_match      = { cidrs = ["10.0.0.0/8"] }
  destination_match = { cidrs = ["any"] }

  category = {
    custom_urls = ["gambling", "malware"]
    feeds       = ["custom-url-feed"]
  }
}

output "matches_categories" {
  value = module.block_categories.matches_url_categories # true
}

ℹ️ custom_urls is required inside the block and validated by the provider against its own category-name list, so a misspelt category is a plan-time error. Omit the whole object to match on address and application alone.

💡 Category matching only applies to traffic the firewall can classify as web, which is why the applications list here names the web App-IDs rather than any.

14 · A rule set with `for_each`
locals {
  # Priorities spaced by 100, so a rule can be inserted without recreating a neighbor.
  rules = {
    deny_geo    = { priority = 500, action = "DenyResetBoth", apps = ["any"], src = { countries = ["KP"] } }
    allow_web   = { priority = 1000, action = "Allow", apps = ["web-browsing", "ssl"], src = { cidrs = ["10.0.0.0/8"] } }
    allow_dns   = { priority = 1100, action = "Allow", apps = ["dns"], src = { cidrs = ["10.0.0.0/8"] } }
    deny_all    = { priority = 999999, action = "DenySilent", apps = ["any"], src = { cidrs = ["any"] } }
  }
}

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

  rulestack_id = module.rulestack.id
  name         = replace(each.key, "_", "-")
  priority     = each.value.priority
  action       = each.value.action
  applications = each.value.apps
  protocol     = "application-default"

  source_match      = each.value.src
  destination_match = { cidrs = ["any"] }
}

output "rule_review" {
  value = {
    for k, m in module.rules : k => {
      permits    = m.permits_traffic
      any_to_any = m.permits_any_source_to_any_destination
      unlogged   = m.logging_is_disabled
      negated    = m.negates_a_match
    }
  }
}

⚠️ These do not apply in parallel. Every write locks the parent rulestack, so four rules on one rulestack are created one after another — and each one commits the whole rulestack, so the commit cost is paid four times.

💡 Note name uses dashes: the shared Palo Alto name rule permits alphanumerics and dashes only, so a for_each key with underscores has to be converted.

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 "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      = { cidrs = ["10.20.0.0/16"] }
  destination_match = { cidrs = ["any"] }

  audit_comment = "Baseline egress, raised by CHG-1042"
}

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"] }

  audit_comment = "Explicit catch-all deny"
}

check "no_any_to_any_allows" {
  assert {
    condition = alltrue([
      module.allow_egress.permits_any_source_to_any_destination == false,
      module.deny_everything_else.permits_any_source_to_any_destination == false,
    ])
    error_message = "A rule allows any source to any destination."
  }
}

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

    # Creating a rule commits the rulestack, so this composition does NOT leave it staged.
    rulestack_committed_by_the_rules = module.allow_egress.creating_a_rule_commits_the_whole_rulestack

    # Still true, and worth carrying: removing a rule later will not commit.
    deletes_will_not_commit = module.allow_egress.deleting_a_rule_does_not_commit
  }
}

🔒 Three modules, and the composition resolves the parent's biggest caveat. A rulestack applied on its own is never committed; because these rules are created in the same apply, their creates commit it — so rulestack_committed_by_the_rules is the honest answer to "do I need a second apply". deletes_will_not_commit is carried alongside because the same asymmetry bites on the way out.

⚠️ The catch-all deny at 999999 is explicit on purpose. no_default_rules_are_created on the rulestack means there is no implicit final rule, so a policy that should end in a deny has to say so.

ℹ️ accounts_agree compares the two modules' rulestack_path_within_the_subscription outputs — the family's comparable form — to confirm the rules landed on the intended rulestack.


📥 Inputs

Group Variables
Placement and identity rulestack_id (force-new), priority (force-new — the identity), name
What it matches source_match, destination_match, applications, protocol / protocol_ports, category
What it does action, enabled, logging_enabled, negate_source, negate_destination
Decryption decryption_rule_type, inspection_certificate_id
Metadata description, audit_comment, tags
Tail timeouts

ℹ️ source_match and destination_match are named that way because source is a reserved Terraform variable name — it is a module block meta-argument, so variable "source" is a hard parse error. The provider's blocks are still source and destination; the twin is renamed too so the pair stays symmetric.

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. Not editable in place
priority number — Force-new. 1–1000000. The rule's identity
action string — Allow, DenySilent, DenyResetServer, DenyResetBoth
applications list(string) — At least one. any is legal and widest
source_match object — cidrs, countries, feeds, local_rulestack_prefix_list_ids
destination_match object — The same plus local_rulestack_fqdn_list_ids
category object null custom_urls required inside when present
protocol / protocol_ports string / list(string) null Exactly one required, provider-enforced
decryption_rule_type string null None, SSLInboundInspection, SSLOutboundInspection
inspection_certificate_id string null Only valid with inbound inspection — enforced here
enabled bool true Matches the provider
logging_enabled bool true The provider defaults this to false
negate_source / negate_destination bool false Matches the provider
description, audit_comment string null Not whitespace-only
tags map(string) {} Available here, not on the rulestack
timeouts object {} All four honored

22 validations, listed rather than totaled. Two on rulestack_id (non-blank, anchored shape). Two on name (the pattern, and the dash rule the provider raises separately). Two on priority (range, whole number). One on action. Two on applications (at least one, no blank entry). Two each on source_match and destination_match (CIDR-or-any, uppercase country codes). One on category. One each on protocol and protocol_ports. One on decryption_rule_type. Two on inspection_certificate_id (anchored certificate ID, and the documented pairing). One each on description and audit_comment. One on timeouts.

Three things are deliberately NOT validated. The ExactlyOneOf across protocol and protocol_ports, and the AtLeastOneOf within each match block — both carried by the provider, which rejects them at plan time with messages naming the arguments. And App-ID names, because Palo Alto's catalog is not published as an enum.


🧾 Outputs

40 outputs: 9 passthrough, 19 derived, 12 constant.

Output Description
id The rule's Resource ID
name, rulestack_id, priority, action, applications, enabled, logging_enabled, tags The arguments as applied
rulestack_name, resource_group_name, rulestack_path_within_the_subscription Parsed from the parent ID
source_match_dimensions, destination_match_dimensions Which dimensions are in use, sorted
source_matches_any_address, destination_matches_any_address, matches_any_application The three "any" facts
permits_traffic The action is Allow
permits_any_source_to_any_destination The widest rule this resource can express
permits_any_application_from_any_source Wide on applications rather than addresses
negates_a_match Either side's match is inverted
effective_decryption_rule_type, performs_decryption Decryption behavior in force
inbound_decryption_without_a_certificate The reported half of the documented pairing
logging_is_disabled Logging turned off
rule_is_disabled Present and not evaluated
protocol_mode Which protocol argument was taken
matches_url_categories A category block was supplied
creating_a_rule_commits_the_whole_rulestack Constant true
deleting_a_rule_does_not_commit Constant true
renaming_requires_replacement_and_the_provider_does_not_enforce_it Constant true
the_priority_is_the_rules_identity_and_is_force_new Constant true
every_write_locks_the_parent_rulestack Constant true
exactly_one_of_protocol_and_protocol_ports_is_required Constant true
at_least_one_match_dimension_is_required_on_each_side Constant true
the_existence_check_can_be_disabled_by_a_provider_feature Constant true
the_rulestack_owns_the_prefix_and_fqdn_lists_this_rule_references Constant true
the_certificate_pairing_is_documented_but_not_enforced_by_the_provider Constant true
tags_are_available_here_and_not_on_the_rulestack Constant true
the_name_validator_is_marked_provisional_in_the_provider_source Constant true

ℹ️ Nothing here is sensitive. A certificate is referenced by ID; its material belongs to the certificate resource.


🧠 Architecture Notes

This is where a rulestack becomes a firewall. The parent carries six threat-prevention engines and no traffic policy at all — the provider hardcodes defaultMode = NONE, so a new rulestack contains no rules. Every allow and deny comes from this resource, which means a review of what a Cloud NGFW actually permits is a review of these, in priority order.

The commit asymmetry is the module's defining fact, and it cuts both ways. Create commits the whole rulestack. Update commits. Delete does not. The favorable half resolves the parent module's biggest caveat: a rulestack applied together with at least one rule is committed by the rule's create, so no second apply is needed. The unfavorable half is sharper — removing a rule takes it out of the staged configuration and leaves the running firewall enforcing it until something else commits. Revoking an Allow is therefore not finished when the apply is. Disabling the rule instead (enabled = false) is an update, so it commits and takes effect immediately, which makes it the better way to withdraw a rule.

Priority is the identity, and NEITHER identity field can be edited in place. The provider documents priority as the primary identifier and marks it force-new, so re-prioritizing is a destroy-and-create — and because the destroy half does not commit, the old rule can still be in force after the apply reports success. Leaving gaps between priorities is not tidiness; it is how you avoid renumbering a neighbor.

The name is not editable either, and that one is undocumented. It is required and not force-new, so a change plans as an in-place update; the update path assigns RuleName nowhere, sending back the record it just read; and the read restores the name from the API response. So the diff applies successfully, changes nothing, and reappears forever. This is a family-wide pattern rather than a quirk here — the prefix list and the certificate carry it too, through a read-from-the-Resource-ID mechanism instead, and only the rulestack is exempt because its own name is force-new. Rename by replacing the rule, and remember that the destroy half of that replacement does not commit.

There is no implicit "any", and that is better than it sounds. Both match blocks are required and each needs at least one of its dimensions, enforced by the provider. Matching everything is written explicitly as cidrs = ["any"] — a literal the provider accepts alongside real CIDRs. So the widest rule in a configuration is visible as text rather than as an absence, which is what makes permits_any_source_to_any_destination computable at plan time. That flag is derived through the any-address facts rather than by matching literals, so it holds however the caller expressed them.

Negation is the one thing a reader will get wrong. A negated source with Allow permits everything except what the block lists — close to the opposite of what the configuration appears to say, and easy to miss among twenty arguments. negates_a_match is emitted separately from the match dimensions for exactly that reason.

One divergence from the provider, and its boundary. logging_enabled defaults to true here and false there. A firewall rule that does not log is a control nobody can audit, and logging is not a paid Palo Alto subscription — so unlike the parent's threat-prevention engines, this suite's exposure-not-spend boundary does not exempt it. It does cost log volume, which is why logging_is_disabled exists as a reviewable flag rather than the module refusing to turn logging off.

And one rename Terraform forces. source is reserved — it is a module block meta-argument, so variable "source" is a hard parse error. The input is source_match, and destination_match is renamed alongside it so the pair stays symmetric rather than one half diverging. The provider's own blocks are still source and destination, and main.tf renders them as such.


🧱 Design Principles

Concern This module's default The caller's opt-out
action No default — a rule must say what it does —
Logging On, diverging from the provider logging_enabled = false, deliberately
Match breadth No default. cidrs = ["any"] must be typed, and is reported —
Decryption None, matching the provider — it needs a certificate and has privacy implications set it explicitly
The certificate pairing Enforced in the documented direction, reported in the other —
ExactlyOneOf / AtLeastOneOf Not restated — the provider rejects both at plan time —
App-ID names Not validated — the catalog is not a published enum —
Deleting a rule Reported as not committing. No module can change it disable instead of deleting

🔒 The empty call cannot be made safe here, because there is no empty call. action, applications, both match blocks and a protocol are all required — a rule must state what it matches and what it does. So this module's compensating work is elsewhere: enforce the value sets, name the restrictive choice in each error message, and emit the breadth of the rule as flags a check block can assert on.


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

⚠️ When removing a rule, follow the apply with a commit — or disable the rule instead. A delete does not commit.


🧪 Testing

Covered by What it proves
terraform validate The resource, its source, destination and category blocks 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 22 rules were driven by deliberately bad inputs, and every local was driven to more than one value by good ones
terraform plan (needs credentials) Whether the rulestack exists, and the ExactlyOneOf / AtLeastOneOf the provider enforces
Nothing offline Whether an App-ID name exists, whether a referenced list exists, or whether a category name is valid
Nothing in Terraform at all Whether the rulestack has been committed since the last delete

⚠️ 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/localRules/1000"
name = "allow-egress"
priority = 1000
action = "Allow"
applications = [
  "web-browsing",
  "ssl",
  "dns",
]
rulestack_name = "rs-edge-eastus"
rulestack_path_within_the_subscription = "/resourceGroups/rg-netsec-eastus/providers/PaloAltoNetworks.Cloudngfw/localRulestacks/rs-edge-eastus"
source_match_dimensions = [
  "cidrs",
]
destination_match_dimensions = [
  "cidrs",
]
source_matches_any_address = false
destination_matches_any_address = true
matches_any_application = false
permits_traffic = true
permits_any_source_to_any_destination = false
negates_a_match = false
effective_decryption_rule_type = "None"
performs_decryption = false
logging_is_disabled = false
rule_is_disabled = false
protocol_mode = "application_default"
matches_url_categories = false
creating_a_rule_commits_the_whole_rulestack = true
deleting_a_rule_does_not_commit = true

🔍 Troubleshooting

Symptom Cause Fix
variable name "source" is reserved Wrote source = { ... } The input is source_match; source is a module meta-argument
rulestack_id must be a local rulestack Resource ID Passed a rule ID or a truncated path It must end at /localRulestacks/<name>
name may only contain alphanumeric characters and dashes An underscore, often from a for_each key Convert with replace(each.key, "_", "-")
priority must be between 1 and 1000000 Out of range And leave gaps — priority is force-new
action must be exactly one of... Deny instead of DenySilent Three of the four values are denies
Each source.cidrs entry must be a valid CIDR block or the literal "any" A hostname or a bad mask Those are the only two forms
Each source.countries entry must be an uppercase two-letter ISO 3166 country code "us" Uppercase only
inspection_certificate_id is only valid when decryption_rule_type is "SSLInboundInspection" A certificate with no inbound inspection The provider documents this and does not enforce it
Plan error naming protocol and protocol_ports Both or neither supplied The provider's ExactlyOneOf; exactly one is required
Plan error naming the four source fields An empty source_match The provider's AtLeastOneOf; use cidrs = ["any"] for any
A deleted rule is still enforced Delete does not commit Commit, or disable the rule instead of deleting it
A re-prioritized rule briefly co-existed with the old one Priority is force-new and the destroy does not commit Expected. Plan renumbering as a maintenance operation
The firewall ignores a new rule entirely The rulestack has no firewall referencing it A rule affects nothing until a firewall uses the rulestack
Traffic is allowed that the rule seems to deny A negated match, or a lower-priority rule matched first Read negates_a_match, then check priority order
No log entries for a matched rule logging_enabled was set to false This module defaults it on; something turned it off
Rules applied one at a time Every write locks the parent rulestack Expected, and each create commits the rulestack

🔗 Related Docs

  • azurerm_palo_alto_local_rulestack_rule — the provider resource, its action values, and the priority note
  • 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, and the three policy-management models
  • az palo-alto cloudngfw local-rulestack — the commit operation a delete does not perform
  • terraform-azurerm-palo-alto-local-rulestack — the parent, and where the threat-prevention engines live
  • terraform-azurerm-palo-alto-local-rulestack-prefix-list — the address groups this rule can reference from either side
  • terraform-azurerm-palo-alto-local-rulestack-fqdn-list — the domain groups this rule can reference from its destination only
  • terraform-azurerm-palo-alto-local-rulestack-certificate — the certificate this rule's inspection_certificate_id takes
  • terraform-azurerm-palo-alto-next-generation-firewall-virtual-network-local-rulestack — the firewall that enforces this rule, once the rulestack is committed
  • terraform-azurerm-resource-group — the resource group
  • This module's SCOPE.md

💙 "Infrastructure as Code should be standardized, consistent, and secure."