Creates a security rule in a Cloud NGFW local rulestack — the traffic policy a rulestack otherwise lacks. Targets
hashicorp/azurerm ~> 4.0.
- 🛡️ 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_enabledtotruewhere the provider defaults it tofalse. 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.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
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
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
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_palo_alto_local_rulestack_rule |
1 (this) |
Two force-new arguments: rulestack_id and priority |
| 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
namecannot 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. Useterraform apply -replace=.... - 🔴
priorityis 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. - 🔴
sourceis a RESERVED Terraform variable name, so this module's inputs aresource_matchanddestination_match. The provider's blocks are stillsourceanddestination. - 🔴
logging_enableddefaults tofalsein the provider. This module defaults it totrue. ⚠️ inspection_certificate_idis documented as valid only withSSLInboundInspection, and the provider does not enforce it. This module does.⚠️ Exactly one ofprotocolandprotocol_portsis required — the provider carries anExactlyOneOf, so it is not restated here. In v4 the old implicit default is gone: writeprotocol = "application-default"explicitly to keep it.⚠️ sourceanddestinationare each required and each need at least one dimension. There is no implicit "any" — matching everything is said withcidrs = ["any"], an explicit literal the provider accepts.⚠️ Every write locks the parent rulestack, so afor_eachover 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.⚠️ tagsare available here and NOT on the parent rulestack — an asymmetry inside one family.- ℹ️ The name validator carries a
TODO - Check thiscomment in the provider's own source. It binds regardless.
| 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 withoutcommit/actionwill fail after writing the rule.
- A Cloud NGFW by Palo Alto Networks Marketplace subscription, and the
PaloAltoNetworks.Cloudngfwresource 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.
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
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 = 1000rather than1on purpose — leave gaps, because priority is force-new and inserting a rule between two adjacent numbers means recreating one of them.
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 |
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_destinationisfalsebecause 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 = 1it 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_sourceis 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:
DenySilentdrops with no response,DenyResetServerresets the server side,DenyResetBothresets both.permits_trafficisfalsefor 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
RuleNamenowhere — 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 destroyor by deleting its module block takes it out of the rulestack's staged configuration and leaves the running firewall enforcing it. Revoking anAllowis therefore not complete when the apply finishes.✅ The remedy, in order of preference: disable the rule instead of deleting it (
enabled = falseis an update, so it commits and takes effect immediately); or follow the delete with a commit — eitheraz 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_enabledtotrue; the provider defaults it tofalse. 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
checkblock 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_dimensionsreports 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_referencessays 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 containing0.0.0.0/0makes this rule unrestricted on that side, while this module's ownpermits_any_source_to_any_destinationstill readsfalse— it sees the rule's literal CIDRs, not the contents of a list. Assert on the prefix-list module'scovers_every_ipv4_addressas 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 withAllow, a negated source is close to the opposite of what the block appears to say.💡
negates_a_matchis 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_decryptionis 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_idis "only valid whendecryption_rule_typeis set toSSLInboundInspection" — 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_idbecause a validation must reference its own variable — which is why its error message namesdecryption_rule_typetoo.
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_modereports 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 specifyprotocol="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_urlsis 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
applicationslist here names the web App-IDs rather thanany.
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
nameuses dashes: the shared Palo Alto name rule permits alphanumerics and dashes only, so afor_eachkey 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_rulesis the honest answer to "do I need a second apply".deletes_will_not_commitis carried alongside because the same asymmetry bites on the way out.
⚠️ The catch-all deny at999999is explicit on purpose.no_default_rules_are_createdon the rulestack means there is no implicit final rule, so a policy that should end in a deny has to say so.ℹ️
accounts_agreecompares the two modules'rulestack_path_within_the_subscriptionoutputs — the family's comparable form — to confirm the rules landed on the intended rulestack.
| 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_matchanddestination_matchare named that way becausesourceis a reserved Terraform variable name — it is amoduleblock meta-argument, sovariable "source"is a hard parse error. The provider's blocks are stillsourceanddestination; 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.
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.
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.
| 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 acheckblock can assert on.
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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.
| 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.
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
| 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 |
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.Cloudngfwresource 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— thecommitoperation a delete does not performterraform-azurerm-palo-alto-local-rulestack— the parent, and where the threat-prevention engines liveterraform-azurerm-palo-alto-local-rulestack-prefix-list— the address groups this rule can reference from either sideterraform-azurerm-palo-alto-local-rulestack-fqdn-list— the domain groups this rule can reference from its destination onlyterraform-azurerm-palo-alto-local-rulestack-certificate— the certificate this rule'sinspection_certificate_idtakesterraform-azurerm-palo-alto-next-generation-firewall-virtual-network-local-rulestack— the firewall that enforces this rule, once the rulestack is committedterraform-azurerm-resource-group— the resource group- This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."

