Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure IoT Central Application Network Rule Set Terraform Module

Manages the IP allow-list for an IoT Central Application β€” a singleton per application that governs which addresses devices may connect from, to the IoT Hub and Device Provisioning Service behind it (azurerm_iotcentral_application_network_rule_set). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • πŸ›‘οΈ The IP allow-list for an IoT Central Application β€” which addresses devices may connect from, to the IoT Hub and Device Provisioning Service behind it.
  • πŸ”΄ It governs DEVICE traffic and nothing else. The underlying Azure object has a second flag for the IoT Central web portal and REST APIs, and the provider hardcodes it to false on every write. This module is never evidence that operator or API access is restricted.
  • πŸ”΄ A singleton per application, whose Resource ID is the application's ID. There is no rule-set name, so two configurations managing it overwrite each other with no collision to warn anyone.
  • πŸ”΄ Deny with an empty ip_rule list denies every device, while apply_to_device is true. Azure reports no error. This module flags it rather than forbidding it, because the provider documents no minimum and a deny-all set is a legitimate posture.
  • πŸ”΄ And apply_to_device = false switches the allow-list OFF rather than narrowing it. Both apply-to flags are then false and the whole rule set applies to nothing β€” emitted as rule_set_is_inert.
  • πŸ”΄ A terraform destroy WIDENS access. There is no delete call; the provider clears the rule set out of the application, which reverts to accepting device connections from anywhere.
  • βœ… Secure defaults, matching the provider's own: default_action = "Deny" makes ip_rule an allow-list, and apply_to_device = true is the only setting under which the allow-list is evaluated.
  • ⚠️ ip_mask takes a single IPv4 address OR a CIDR block β€” the prefix is optional. IPv4 only. Rule names carry a charset and a 128-character cap this module mirrors, and must be unique case-insensitively.
  • ⚠️ The service caps the list at 100 rules and the schema does not know it, so a 101-entry configuration plans clean and fails at apply. Reported, not enforced.
  • βœ… Everything except the application reference updates in place, which makes revising an allow-list cheap.
  • ℹ️ No tags. The resource exposes none; tag the application.

πŸ’‘ Why it matters: This is a small resource with three outsized failure modes, and none of them produces an error. One configuration too many and the policy silently becomes whichever pipeline ran last. One range too few and every device in the fleet stops reporting β€” showing a bare 401 Unauthorized that reads as a credential problem. And one boolean the wrong way and the whole allow-list is carried in state, shown in every plan, and applied to nothing at all.

❀️ Support this project

If this module saves you time, please consider supporting its continued development:


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

flowchart TB
  rg["terraform-azurerm-resource-group"]
  app["terraform-azurerm-iotcentral-application, which also owns public_network_access_enabled and defaults it to false"]
  rules["terraform-azurerm-iotcentral-application-network-rule-set"]
  org["terraform-azurerm-iotcentral-organization: consumes the SAME application id but governs a permissions hierarchy, not network access"]
  devices["DEVICES connecting to the IoT Hub and Device Provisioning Service behind the application. The ONLY surface this rule set can govern, and only while apply_to_device is true, which is the default."]
  ops["THE IoT CENTRAL WEB PORTAL AND REST API. NEVER governed by this rule set. The provider hardcodes the portal-and-API flag to false on every write, so restricting operator access is an identity and Conditional Access question."]
  singleton["ONE RULE SET PER APPLICATION, addressed by the application's own Resource ID. There is no rule-set name, so two configurations managing it overwrite each other with no collision to warn anyone."]

  rg -->|"name, location"| app
  app -->|"id, as iotcentral_application_id"| rules
  app -->|"id, for a different purpose"| org
  rules -.->|"CANNOT reach, at any setting"| ops
  rules -->|"allow-list governs, while apply_to_device is true"| devices
  singleton -->|"applies to"| rules
  app -->|"its own public network access is a SEPARATE layer neither module can see"| rules

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class rules mine;
  class app,singleton keystone;
  class rg,org,devices,ops sib;
Loading

🧬 What this module builds

flowchart TB
  appid["iotcentral_application_id: REQUIRED, and the ONLY force-new field. Everything else updates in place, which makes revising an allow-list cheap."]
  same["THE RULE SET'S OWN ID IS THE APPLICATION'S ID. There is no rule-set name, so the ID check is anchored with a dollar terminator and terraform import takes the application id."]
  da["default_action: Deny by default, which makes ip_rule an ALLOW-LIST. Setting Allow permits unmatched traffic and turns the whole resource into a no-op."]
  atd["apply_to_device: true by default, and the ONLY setting under which this rule set does anything. It governs device connectivity to the IoT Hub and Device Provisioning Service, which is the sole surface reachable from here."]
  inert["apply_to_device false SWITCHES THE ALLOW-LIST OFF rather than narrowing it. Both apply-to flags are then false, so default_action and every ip_rule entry are stored and applied to NOTHING. Emitted as rule_set_is_inert."]
  rules["ip_rule: named entries, empty by default. ip_mask takes a single IPv4 ADDRESS or a CIDR block, the prefix being OPTIONAL, and IPv4 only. Names carry a mirrored charset and a 128 character cap, and must be unique case-insensitively."]
  denyall["DENY PLUS AN EMPTY LIST DENIES EVERY DEVICE while apply_to_device is true, and Azure reports no error. Deliberately NOT validated, because the provider documents no minimum and a deny-all set is a legitimate commissioning or incident posture. Emitted as denies_all_access instead."]
  cap["THE SERVICE CAPS THE LIST AT 100 RULES and the schema carries no maximum, so a 101 entry configuration plans clean and fails at apply. Raisable by support request, so REPORTED and not enforced."]
  destroy["A DESTROY WIDENS ACCESS. There is no delete call: the provider clears the rule set out of the application and PUTs the application back without it. Emitted as destroy_removes_the_restriction."]
  notags["NO tags attribute exists, confirmed against the schema. Tag the application."]
  this["azurerm_iotcentral_application_network_rule_set.this"]
  outputs["id, default_action_is_deny, applies_to_device_connectivity, governs_device_traffic_only, rule_set_is_inert, permitted_cidrs, permitted_rule_count, ip_rule_count_within_service_limit, denies_all_access, permits_entire_internet, every_rule_is_an_allow_rule, destroy_removes_the_restriction, is_singleton_per_application"]

  appid -->|"parent"| same
  same -->|"validated"| this
  da -->|"secure default"| this
  atd -->|"secure default"| this
  rules -->|"allow-list"| this
  atd -->|"set false"| inert
  inert -->|"flagged, not forbidden"| outputs
  da -->|"combined with an empty list"| denyall
  rules -->|"combined with Deny"| denyall
  rules -->|"counted against"| cap
  cap -->|"reported, not enforced"| outputs
  denyall -->|"flagged, not forbidden"| outputs
  notags -->|"universal tail omitted"| this
  this -->|"exports"| outputs
  this -->|"destroyed"| destroy

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this mine;
  class da,atd mine;
  class denyall,same,inert,destroy keystone;
  class appid,rules,notags,outputs,cap sib;
Loading

Resource inventory

Resource Count Notes
azurerm_iotcentral_application_network_rule_set.this 1 The keystone. One rule set per application.
ip_rule block 0..n The allow-list. Rendered from a canonical map keyed on the name.
timeouts block 0..1 All four operations exist.
tags β€” None. The resource exposes no tags attribute.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Microsoft.IoTCentral (iotApps)
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite β€” argument facts confirmed against the live provider schema; the behavioural facts below it cannot carry are confirmed against the provider source, and the service limits against Microsoft's documentation:

  • πŸ”΄ THE PORTAL-AND-API FLAG IS HARDCODED OFF. The underlying Azure object carries two apply-to flags, one for device connectivity to the IoT Hub and DPS and one for connectivity via the IoT Central web portal and REST APIs. The provider sets the second to false on every create and every update, never reads it back, and exposes no argument for it. So this resource governs device traffic only, at every setting β€” it is not evidence that operator or API access is restricted, and it cannot be made so. Invisible in the schema and absent from the registry documentation; confirmed in the provider source, and corroborated by Microsoft's IoT Central security baseline.
  • πŸ”΄ apply_to_device = false switches the allow-list OFF, it does not narrow it. Both flags are then false and nothing is evaluated; the provider's own duplicate-import guard treats that combination as an unconfigured rule set.
  • πŸ”΄ A terraform destroy WIDENS access. The provider makes no delete call. It clears the rule set out of the application and PUTs the application back without it, reverting the application to accepting device connections from anywhere. No destroy plan says so.
  • πŸ”΄ A rule set cleared out of band makes plan FAIL, not re-create. When the application exists but has no rule set, the read returns an error rather than marking the resource gone.
  • πŸ”΄ The rule set's Resource ID is identical to the application's. One rule set per application, no rule-set name, so nothing distinguishes them by shape and two owners produce no collision β€” they write the same object.
  • ⚠️ terraform import takes the application's ID, which looks like importing the wrong resource.
  • ⚠️ The application ID is parsed CASE-SENSITIVELY. A copied ID that lower-cases /resourcegroups/ or microsoft.iotcentral is refused. This module mirrors that so the failure lands at validate.
  • πŸ”΄ default_action = "Deny" plus an empty ip_rule list denies every device, while apply_to_device is true. Valid configuration; no error.
  • πŸ”΄ default_action = "Allow" makes the resource a no-op β€” unmatched traffic is permitted and the rules restrict nothing. The value is case-sensitive; "deny" is refused.
  • ⚠️ ip_mask takes a single IPv4 address OR a CIDR block β€” the prefix group in the provider's validator is optional, and Microsoft documents both forms. IPv4 only: IPv6 has no accepted form here and is not supported on IoT Hub or DPS at all.
  • ⚠️ The provider does not bound the octets to 255. 256.0.0.1 satisfies its regex and reaches Azure.
  • ⚠️ ip_rule.name carries a charset and a 128-character cap the schema does not show: ASCII letters and digits plus - : . + % _ # * ? ! ( ) , = @ ; '. The validator is borrowed from the IoT Hub service and its message names a non-existent argument called ip_rule_name.
  • ⚠️ ip_rule names must be unique, case-insensitively, per Microsoft. The provider performs no de-duplication, so what a duplicate does is undefined rather than documented.
  • ⚠️ The service caps the list at 100 rules and the schema carries no maximum. A 101-entry configuration plans clean and fails at apply. Microsoft documents the cap as raisable by support request.
  • ⚠️ Every write is a PUT of the whole application, and only create takes a lock. Update and delete perform an unlocked read-modify-write of the entire application model, which is also why all three write timeouts are budgeted at 30 minutes.
  • ⚠️ A blocked device sees a bare 401 Unauthorized whose message does not mention the IP rule.
  • ⚠️ It interacts with the application's own public_network_access_enabled, which the sibling application module defaults to false. Neither module can see the other's setting.
  • ℹ️ There is no per-rule deny. The underlying object has a per-rule action field, the provider does not expose it, and its only defined value is Allow.
  • ℹ️ The ARM API version behind this resource is a PREVIEW version, 2021-11-01-preview.
  • βœ… Only iotcentral_application_id is force-new. There is no CustomizeDiff on this resource at all, so there is no conditional or one-directional force-new to discover.
  • No tags attribute exists.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy. Use a CanNotDelete management lock on the application.

πŸ”‘ Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the rule set Contributor the IoT Central Application
Read the rule set Reader the application

πŸ’‘ Scope it to the application resource, not its resource group. That is the whole requirement.

⚠️ Understand the blast radius, because it is unusual. A principal who can edit this rule set can cut off every device connected to the application, or open it to the entire internet. Neither destroys data, and both are production incidents β€” so this is a change-control question rather than a permissions one (example 4).

πŸ”΄ There is no sub-application scope to grant, because every write is a PUT of the whole application. The provider reads the application, replaces its network rule set, and writes the entire model back. So the same grant also permits rewriting the application's display name, subdomain, template and public network access. A role that looks tighter than the application does not exist. No published Microsoft role definition is scoped to this property, so the table above is the least-privilege grant that works rather than a citation.

βœ… Plan access here is not credential access. The read calls a single Get and populates three non-secret fields; nothing in this resource's API surface lists keys, and no output is sensitive.

Azure Prerequisites

  • An existing IoT Central Application, passed as an attribute reference (example 2).
  • The Microsoft.IoTCentral resource provider registered on the subscription.
  • The egress ranges the devices actually connect from β€” the hard part of using this resource at all (example 5). Microsoft warns specifically to list the address of any proxy the devices connect through rather than the devices' own addresses.

πŸ“ Module Structure

terraform-azurerm-iotcentral-application-network-rule-set/
β”œβ”€β”€ providers.tf   # required_version + the pinned azurerm provider. No provider block.
β”œβ”€β”€ variables.tf   # iotcentral_application_id, default_action, apply_to_device, ip_rule, timeouts
β”œβ”€β”€ main.tf        # the keystone `this` + ip_rule from a canonical map + dynamic timeouts
β”œβ”€β”€ outputs.tf     # id first, then the allow-list, then the derived posture flags
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "app_network_rules" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iotcentral-application-network-rule-set.git?ref=v1.0.0"

  # Attribute reference, not a literal (example 2).
  iotcentral_application_id = module.iotcentral_application.id

  # default_action defaults to "Deny" and apply_to_device to true β€” both kept.
  ip_rule = [
    { name = "hq-egress", ip_mask = "203.0.113.0/24" },
    { name = "plant-uk", ip_mask = "198.51.100.16/28" },
  ]
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

πŸ”΄ An empty ip_rule list here would deny every device. Read example 3 before applying one.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
iotcentral_application_id string terraform-azurerm-iotcentral-application output id
default_action string caller β€” defaults to Deny
apply_to_device bool caller β€” defaults to true
ip_rule list(object(...)) caller β€” the allow-list; empty by default
timeouts object(...) caller β€” all four operations exist

Emits

Output Description Consumed by
id The Resource ID β€” identical to the application's. imports, review
iotcentral_application_id The governed application. Force-new. review
default_action Allow or Deny. security review
default_action_is_deny πŸ”’ Derived. Necessary but not sufficient. Assert true. security review
applies_to_device_connectivity πŸ”’ Whether the allow-list is in force at all. security review
governs_device_traffic_only πŸ”΄ Always true. The portal and REST API are never governed. security review
rule_set_is_inert πŸ”΄ Derived. true means the rule set applies to nothing. Assert false. security review
permitted_cidrs Sorted allow-list, for diffing environments. security review
permitted_cidrs_by_name Keyed by rule name. operations
permitted_rule_count How many ranges. review
ip_rule_count_within_service_limit ⚠️ Derived. The 100-rule service cap, reported not enforced. capacity review
denies_all_access πŸ”΄ Derived. The key availability output. availability review
permits_entire_internet πŸ”’ Derived. IPv4 only, which is complete here. security review
every_rule_is_an_allow_rule ℹ️ Always true. No per-rule deny exists. design review
destroy_removes_the_restriction πŸ”΄ Always true. A destroy WIDENS access. change review
is_singleton_per_application Always true. change review

No secret is accepted and none is emitted.

πŸ“š Example Library

Values these examples reference but do not create are declared inputs:

variable "organization_id" {
  description = "organization id of an existing resource these examples reference."
  type        = string
}
1 Β· What the rule set governs
IoT Central Application "iotc-plant-telemetry"
  β”œβ”€β”€ web portal + REST API      <- NEVER governed by this rule set, at any setting
  └── behind it, managed by Azure: an IoT Hub + a Device Provisioning Service
        └── DEVICES connect here <- the ONLY surface this rule set can govern

network rule set (this module):
  default_action  = "Deny"    -> ip_rule is an allow-list for device connections
  apply_to_device = true      -> the allow-list is evaluated at all
  apply_to_device = false     -> the allow-list is evaluated for NOTHING

πŸ”΄ The Azure object has two apply-to flags and the provider only exposes one. The other governs "connectivity via the IoT Central web portal and APIs", and the provider hardcodes it to false on every create and every update β€” no argument, never read back, no setting of apply_to_device changes it. Confirmed against the provider source. Microsoft's IoT Central security baseline states the service-side consequence plainly: the web UI and APIs continue to work through their public endpoints.

πŸ”΄ So this module is not evidence that operator or API access is restricted, and it cannot be made so. If that is the requirement, it is an identity and Conditional Access question, not a network one.

ℹ️ An IoT Central Application is managed SaaS. The IoT Hub and Device Provisioning Service behind it are not resources you can address β€” which is exactly why apply_to_device exists on this rule set rather than on a hub module.

πŸ’‘ One audience, and it is the hard one. Devices reach the hub from wherever they are deployed, and their egress ranges are usually much harder to enumerate than an office's (example 5) β€” with the extra trap that the address to list is the proxy's, where devices connect through one, not the devices' own.

⚠️ This is separate from the application's own public_network_access_enabled, which the sibling module defaults to false. When public access is disabled, this allow-list is a second layer; if it is later enabled, this becomes the control that matters. Neither module can see the other's setting.

ℹ️ Organizations (terraform-azurerm-iotcentral-organization) consume the same application ID but govern a permissions hierarchy β€” nothing to do with network access.

2 Β· πŸ”΄ One rule set per application, and its ID is the application's
application ID:  /subscriptions/.../providers/Microsoft.IoTCentral/iotApps/iotc-plant-telemetry
rule set ID:     /subscriptions/.../providers/Microsoft.IoTCentral/iotApps/iotc-plant-telemetry
                 ^ identical. There is no rule-set name.

πŸ”΄ Azure exposes exactly one network rule set per application, addressed by the application itself. So two Terraform configurations that both manage the rule set for one application do not collide β€” there is no name to collide on. They overwrite each other, and the policy in force is whichever pipeline applied last.

πŸ”΄ Nothing in a plan reveals the other configuration. Each sees its own desired state and reports a clean diff. is_singleton_per_application is emitted as a constant true to say so where an adopter will read it.

βœ… So decide who owns it, once. If a platform team owns the application and an application team owns the allow-list, that works β€” but only one of them may manage this resource.

⚠️ The ID check is anchored with $ because nothing distinguishes an application ID from a rule-set ID by shape; the anchor at least rejects a child path appended to either.

πŸ”΄ Pass module.iotcentral_application.id, not a literal, so Terraform creates the application first and destroys it last.

3 Β· πŸ”΄ Deny plus an empty list locks out every device
default_action  = "Deny"   # the default
apply_to_device = true     # the default
ip_rule         = []       # the default

# Result: no device may connect. Telemetry stops.
# (The web portal and REST API are unaffected β€” this rule set never governs them.)

πŸ”΄ Azure reports no error, because this is a valid configuration. Microsoft states the equivalence directly: an empty IP filter on the underlying hub blocks connections from all addresses, the same as a rule blocking 0.0.0.0/0. That is a coherent thing to ask for.

βœ… This module deliberately does not reject it. The provider documents no minimum rule count, ip_rule is genuinely optional, and a deny-all set is a legitimate posture β€” while an application is being commissioned, or while responding to an incident. Rejecting it would refuse legal input.

βœ… Instead the state is emitted, so it is asserted rather than assumed:

output "availability_check" {
  value = module.app_network_rules.denies_all_access # expect false in production
}

πŸ”΄ apply_to_device = true is what makes it real rather than decorative β€” and it is also what makes it hard to diagnose. A locked-out fleet stops sending telemetry, which looks like a device problem, a connectivity problem, or nothing at all until a dashboard goes flat. Microsoft documents what the device actually sees: a connection from an address that is not explicitly allowed receives an unauthorized 401 status code, and the response message does not mention the IP rule. So the first instinct is to suspect credentials and start rotating keys, which cannot fix it.

πŸ”΄ And the opposite mistake is quieter still. With apply_to_device = false, denies_all_access is false and permitted_cidrs looks healthy β€” because nothing is being denied at all. Both apply-to flags are then false, so the whole rule set applies to no surface. Assert rule_set_is_inert = false alongside denies_all_access = false; neither one alone tells you the allow-list is working.

πŸ’‘ The safe commissioning order is to add the allow-list ranges before anything relies on them, since every field except the application reference updates in place β€” so you can build the list incrementally with no replacement (example 6).

⚠️ And the reverse trap: default_action = "Allow" makes the whole resource a no-op. If you are tempted to set it to make something work, the fix is a missing ip_rule, not a weaker default.

4 Β· Who can change this, and what that means
Contributor on the APPLICATION  ->  can rewrite the allow-list
                                ->  can set default_action = "Allow"   (open it up)
                                ->  can set ip_rule = []               (close it entirely)

⚠️ Neither of those destroys data, and both are production incidents. That combination is unusual enough to call out: the normal instinct is to protect resources whose loss is permanent, and this one's risk is availability and exposure instead.

πŸ”’ Scope Contributor to the application resource, not its resource group β€” but know that the application is as tight as it gets. Every write here is a PUT of the whole application model, so there is no rule-set sub-resource to scope a role to, and the grant that lets someone edit the allow-list also lets them rewrite the application's display name, subdomain, template and public network access.

βœ… Then treat changes here as change-controlled rather than permission-controlled. The useful gate is a review of denies_all_access and permits_entire_internet in a plan, not a narrower role β€” because no Azure role distinguishes "add one office range" from "permit the internet".

πŸ’‘ A pipeline that applies this module should print those two outputs. They are the two ways this resource goes wrong, and both are one line to check.

ℹ️ Reading the rule set needs only Reader on the application, so an auditor does not need write access to verify the posture.

5 Β· The allow-list, and why the device list is hard
ip_rule = [
  # Sites with stable egress β€” the easy half:
  { name = "plant-uk", ip_mask = "198.51.100.16/28" },
  { name = "plant-de", ip_mask = "198.51.100.32/28" },

  # A single host needs no prefix β€” a bare address is legal:
  { name = "jump-host", ip_mask = "203.0.113.5" },

  # ...and /32 says the same thing, if you prefer it explicit:
  { name = "gateway-uk", ip_mask = "198.51.100.7/32" },
]

βœ… ip_mask takes a single IPv4 address OR a CIDR block, and the prefix is optional. The provider's validator makes the /bits group optional and Microsoft's wording is "provide a single IPv4 address or a block of IP addresses in CIDR notation". Both forms above are accepted. An earlier revision of this module required the prefix, on the reasoning that "rejecting a bare address costs nothing" β€” that was wrong, and a failed variable validation blocks terraform destroy as well as apply, so the rule could have left a rule set created in the portal or by CLI unmanageable and undestroyable here.

⚠️ IPv4 only, and there is no IPv6 form to reach for. The provider's validator is a dotted-quad regex, and Microsoft states that IPv6 is not supported on IoT Hub or DPS. This module refuses an IPv6 value with a message that says why, rather than deferring the failure to apply.

⚠️ The octets are not range-checked, by the provider or by this module. 256.0.0.1 satisfies the shape rule and reaches Azure, which is where it fails. Azure's exact response to one is not documented, so a check here would be a guess β€” and a wrong guess would block destroying anything that holds one.

⚠️ name is required, and carries a charset and a 128-character cap that this module mirrors: ASCII letters and digits plus - : . + % _ # * ? ! ( ) , = @ ; '. A space, a forward slash, a backslash, an ampersand, a bracket or a quote is refused. Mirroring it is a genuine lift rather than a duplicate, because a provider-side schema validator does not fire through a module boundary β€” without the mirror, name = "hq egress" reaches plan before failing. Two curiosities worth knowing: the validator is borrowed from the IoT Hub service rather than written for IoT Central, and its own message names an argument called ip_rule_name, which does not exist here.

⚠️ Names must be unique, case-insensitively. Microsoft documents the name as "a unique, case-insensitive, alphanumeric string up to 128 characters long", and the provider performs no de-duplication of its own β€” so what a duplicate actually does is undefined rather than documented. This module rejects duplicates instead of finding out: a detectable mistake, unlike the judgement calls it leaves alone.

⚠️ The service caps the list at 100 rules and the provider does not know it. IoT Hub and DPS each document a 100-rule limit, raisable through an Azure support request. There is no maximum in the schema, so a 101-entry configuration plans clean and fails at apply. This module reports the count through ip_rule_count_within_service_limit rather than enforcing a cap the vendor can lift.

ℹ️ Every entry is an ALLOW rule. The Azure object has a per-rule action field, the provider does not expose it, and its only defined value today is Allow β€” so "permit this range except that host" cannot be expressed here, unlike the equivalent DPS portal experience. Denial comes from default_action alone.

πŸ”΄ Devices are the harder list, and apply_to_device = true means they need one. Fixed sites have stable egress ranges; devices on mobile networks or consumer broadband do not. If you genuinely cannot enumerate them, setting apply_to_device = false is a legitimate decision β€” but make it explicitly and record it, because applies_to_device_connectivity will report false and a reviewer will ask.

πŸ”’ 0.0.0.0/0 in the list defeats the whole resource and is much harder to spot than default_action = "Allow" β€” it hides as one plausible-looking entry. permits_entire_internet checks for it. Only the IPv4 form is checked, and that is complete rather than partial: ::/0 cannot reach this resource at all, because the provider's validator has no IPv6 form.

ℹ️ Order is irrelevant twice over. Microsoft states that IP filter rules "are allow rules and are applied without ordering", and this module renders the blocks from a map keyed on the lowercased name, so reordering your list produces no diff either. The provider's own block is a list, so order would otherwise be significant to Terraform β€” the canonical key is what neutralises it, and renaming a rule moves it.

6 Β· Revising the allow-list is cheap
# Adding a site is an in-place update β€” no replacement, no downtime:
ip_rule = [
  { name = "hq-egress", ip_mask = "203.0.113.0/24" },
  { name = "plant-uk", ip_mask = "198.51.100.16/28" },
  { name = "plant-fr", ip_mask = "198.51.100.48/28" }, # new
]

βœ… Only iotcentral_application_id is force-new. default_action, apply_to_device and the entire ip_rule collection update in place, so the allow-list can be revised as often as the estate changes.

πŸ’‘ Which is what makes incremental commissioning safe (example 3): add ranges as sites come online, in as many applies as you like, without ever replacing the rule set.

⚠️ There is no documented grace period and no draining. Microsoft publishes no propagation time or SLA for an IP filter change β€” the portal says only that "the update is in progress" β€” so treat the window as unknown and non-zero rather than instant in either direction. The safe consequence is the same either way: sequence a re-addressing exercise as add-then-remove, not as an edit, so no device is outside the list at any point.

⚠️ And the write is not the small operation it looks like. The provider reads the application, replaces its network rule set and PUTs the entire application model back, then polls β€” which is why the provider budgets 30 minutes each for create, update and delete rather than seconds. Only create takes a lock, so a composition that edits the application and its rule set concurrently can have one write overwrite the other's fields.

βœ… permitted_cidrs is emitted sorted, which makes it useful for diffing environments: dev and prod allow-lists should differ in known ways, and a sorted list makes an unexpected difference obvious.

ℹ️ permitted_cidrs_by_name is the map to read when checking one specific site, since names are how Azure identifies the rules.

7 Β· Why there is no `tags` input
azurerm_iotcentral_application_network_rule_set:  no `tags` attribute exists.
azurerm_iotcentral_application:                   `tags` exists β€” tag the APPLICATION.

ℹ️ The usual tags tail is omitted because the resource has none, confirmed against the schema rather than assumed. A rule set is policy attached to an application, not an independently ownable thing.

βœ… Tag the application instead. That is the resource a cost report or an ownership query can see, and its lifecycle is the one a tag would describe.

πŸ’‘ The same reasoning applies to diagnostic settings. There is nothing on a rule set to diagnose; the application and the IoT Hub behind it are where connection failures show up β€” which is where you would look to find out that this allow-list rejected something.

⚠️ Note that "rejected by the allow-list" is not always obvious in those logs. A device that cannot connect looks much like a device that is offline, which is the practical reason denies_all_access is emitted here (example 3).

8 Β· Importing, and the ID that looks wrong
terraform import 'module.app_network_rules.azurerm_iotcentral_application_network_rule_set.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-iot-platform/providers/Microsoft.IoTCentral/iotApps/iotc-plant-telemetry"

⚠️ That is the application's ID, and it is correct. Because the rule set is a singleton addressed by its application (example 2), there is no rule-set ID to import β€” which reliably looks like a mistake the first time.

βœ… Importing is the right way to adopt an existing policy. Import first, then run a plan and read it: the plan shows the difference between the live allow-list and your configuration, which is the safest way to discover what was actually in place.

πŸ”΄ Do not skip that plan. If your configuration has an empty ip_rule and the live application has ten ranges, the first apply removes all ten and locks out the fleet (example 3). The import succeeds and tells you nothing.

πŸ”΄ And an untouched rule set is taken over silently, with no import step at all. The provider's duplicate-detection guard fires only when the existing rule set is not the allow-all triple of apply_to_device = false, default_action = "Allow" and an empty list. Every application is created with a rule set already attached, so a first apply against a never-configured application raises no "resource already exists" error β€” it simply takes ownership. The guard only protects you against overwriting restrictions someone has already configured, for example in the portal.

⚠️ A rule set cleared out of band breaks plan rather than proposing a re-create. When the application exists but carries no network rule set, the provider's read returns an error instead of marking the resource gone, so the recovery is to re-import the application's ID rather than to re-apply.

πŸ’‘ Write the configuration from the plan, not from memory β€” then re-plan until it is clean.

9 Β· What a review should assert
output "rule_set_review" {
  value = {
    id         = module.app_network_rules.id
    deny_first = module.app_network_rules.default_action_is_deny         # assert true
    devices    = module.app_network_rules.applies_to_device_connectivity # assert true
    inert      = module.app_network_rules.rule_set_is_inert              # πŸ”΄ assert false
    locked_out = module.app_network_rules.denies_all_access              # πŸ”΄ assert false
    wide_open  = module.app_network_rules.permits_entire_internet        # πŸ”’ assert false
    count      = module.app_network_rules.permitted_rule_count
    in_limit   = module.app_network_rules.ip_rule_count_within_service_limit # ⚠️ assert true
    ranges     = module.app_network_rules.permitted_cidrs
    singleton  = module.app_network_rules.is_singleton_per_application   # always true
    devices_only = module.app_network_rules.governs_device_traffic_only  # always true
  }
}

πŸ”΄ inert, locked_out and wide_open are the three assertions that matter, and they fail in three different directions β€” no control at all, an outage, and an exposure. All three are false in a healthy configuration, and no two of them substitute for the third.

βœ… deny_first should be true. false means default_action = "Allow" and the rest of this output is decorative.

πŸ”΄ inert = true is the one that reads healthiest while being worst. With apply_to_device = false the allow-list is stored, shown in every plan and applied to nothing β€” locked_out is false and ranges looks right, because nothing is being evaluated. Assert it explicitly; nothing else in this output implies it.

πŸ”΄ devices_only is a constant true, and it is a scope warning rather than a health check. This rule set never governs the IoT Central web portal or REST API, at any setting, so do not read a clean review here as evidence that operator or API access is restricted.

πŸ”΄ singleton is a constant true β€” read it as "and confirm no other configuration manages this application's rule set" (example 2). It cannot be checked from here.

⚠️ in_limit is the capacity assertion, and it is reported rather than enforced: the 100-rule cap is a service limit that Microsoft documents as raisable by support request, so this module will not refuse a configuration that exceeds it β€” Azure will, at apply.

πŸ’‘ What no output can tell you is whether the permitted ranges are the right ranges. ranges being plausible is not the same as it being complete, and an incomplete allow-list looks exactly like a device fault β€” the device sees a bare 401, not a message about an IP rule.

10 Β· The offline gate, and what a destroy removes
terraform init -backend=false
terraform validate
terraform fmt -check

⚠️ terraform validate run from inside this folder proves type-correctness and nothing about the variable validations β€” it evaluates no variables, so none of the seven validation {} blocks fires. It is the right command for the Runbook and the wrong one to cite as a check of the rules below.

βœ… What the module's own checks prove, once a value reaches them: iotcentral_application_id is an IoT Central Application Resource ID anchored to that exact type and correctly cased; default_action is Allow or Deny, case-sensitively; every ip_rule name is non-empty, unique case-insensitively, and within the provider's charset and 128-character cap; and every ip_mask is an IPv4 address with an optional /0-32 prefix, with an IPv6 value refused by name.

πŸ’‘ Where each command actually fires them. terraform console -no-color with an immediate end-of-input on stdin fires all of them, which makes it the real offline harness β€” drive it with a deliberately bad .tfvars. A terraform validate on a calling configuration fires them too, with the single exception of a condition that reads a second variable; every condition here reads only its own variable, so a calling configuration fires all seven. Only validate from inside the module's own directory fires none.

βœ… Proved that way, not asserted. Two rules named hq and HQ fire the duplicate check; "deny" in lowercase fires the enum; name = "hq egress" fires the charset mirror; 2001:db8::/32 fires both the shape rule and the IPv6 message. And the cases that must pass were driven too, because a widening is only real if the widened value is accepted: a bare 203.0.113.5, a 0.0.0.0/0, a 128-character name, every legal special character in the charset, and a 256.0.0.1 that this module deliberately leaves to Azure.

⚠️ What it cannot prove: that the application exists, that the ranges are correct or complete, or that no other configuration manages the same rule set.

terraform destroy on this module:
  issues NO delete call  ->  the provider clears the rule set out of the application
                         ->  and PUTs the whole application back without it
  removes the RULE SET   ->  the application survives
                         ->  the allow-list is gone, so the restriction is gone
                         ->  access reverts to the application's own public_network_access setting

πŸ”΄ A destroy here is a widening, not a narrowing. Removing the rule set removes the allow-list β€” so unless the application itself has public network access disabled, devices may connect from anywhere. That is the opposite of most destroys in this library, and worth knowing before running one to "clean up". It is emitted as the constant destroy_removes_the_restriction, because no destroy plan says it.

⚠️ There is no delete API call involved. The provider reads the application, sets its network rule set to nothing, and writes the whole application model back β€” the field is omitted from the request and Azure's full-replace semantics clear it. So a destroy is an update to the application wearing a destroy's clothes, which is also why it is budgeted 30 minutes.

βœ… Which is another reason the sibling application module defaults public_network_access_enabled to false β€” it is the backstop if this policy is ever removed.

11 Β· Two layers: this allow-list and the application's own switch
# In the application module (sibling):
public_network_access_enabled = false   # its default β€” nothing public at all

# Here:
default_action = "Deny"
ip_rule        = [{ name = "hq-egress", ip_mask = "203.0.113.0/24" }]

ℹ️ These are two independent controls and neither module can see the other. The application's switch decides whether there is a public surface at all; this rule set decides which addresses devices may reach the hub and DPS from, if there is.

βœ… With public_network_access_enabled = false, this allow-list is defence in depth β€” a second layer that takes effect the moment anyone enables public access, deliberately or by accident.

⚠️ With public access enabled, this rule set is the primary control, and an incomplete allow-list is the only thing between the application and the internet.

πŸ”΄ The failure mode to watch is the pair drifting. Someone enables public access on the application for a diagnostic and forgets; the allow-list is what saves the situation, so it should already be correct rather than being tightened afterwards.

πŸ’‘ Review the two together, and preferably in the same pull request. The application module emits public_network_access_enabled and this one emits default_action_is_deny and permitted_cidrs β€” three values that only make sense side by side.

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

# ── The application. Its own public-access switch is the backstop layer ─────
module "iotcentral_application" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iotcentral-application.git?ref=v1.0.0"

  name                = "iotc-plant-telemetry"
  resource_group_name = module.rg.name
  location            = module.rg.location
  sub_domain          = "contoso-plant-telemetry"
  display_name        = "Plant telemetry"

  # That module defaults this to false β€” the backstop of examples 10 and 11.
  # public_network_access_enabled = false

  tags = { owner = "ot-platform" }
}

# ── This module: the allow-list. EXACTLY ONE config may own it (example 2) ──
module "app_network_rules" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iotcentral-application-network-rule-set.git?ref=v1.0.0"

  iotcentral_application_id = module.iotcentral_application.id

  # Deny by default, so ip_rule is an allow-list. Stated explicitly for the reader.
  default_action = "Deny"

  # true by default, and the only setting under which the allow-list is evaluated at all.
  # Setting false would switch it off rather than narrow it (example 3).
  apply_to_device = true

  # DEVICE egress ranges only β€” this rule set never governs the portal or the REST API.
  # List the PROXY address where devices connect through one (example 5).
  ip_rule = [
    { name = "plant-uk", ip_mask = "198.51.100.16/28" },
    { name = "plant-de", ip_mask = "198.51.100.32/28" },
    # A bare address is legal; the CIDR prefix is optional.
    { name = "gateway-uk", ip_mask = "198.51.100.7" },
  ]
}

# ── Organizations: same application, entirely different concern (example 1) ──
module "plant_orgs" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iotcentral-organization.git?ref=v1.0.0"

  iotcentral_application_id = module.iotcentral_application.id
  organization_id           = "plant-uk"
  display_name              = "Plant UK"
}

output "iot_access_posture" {
  value = {
    # πŸ”΄ The three that matter, and they fail in three different directions (example 9).
    inert      = module.app_network_rules.rule_set_is_inert        # assert false
    locked_out = module.app_network_rules.denies_all_access        # assert false
    wide_open  = module.app_network_rules.permits_entire_internet  # assert false
    deny_first = module.app_network_rules.default_action_is_deny   # assert true
    in_limit   = module.app_network_rules.ip_rule_count_within_service_limit # assert true
    ranges     = module.app_network_rules.permitted_cidrs
  }
}

πŸ”’ What the composition gets right: default_action and apply_to_device stated explicitly even though they are the defaults, so a reader sees the posture without checking the module; the ranges commented as device egress so nobody adds an office range expecting it to gate the portal; a bare address alongside a CIDR block to show both are legal; the application's own public-access switch noted as the backstop layer; organizations shown alongside to make clear they are unrelated to network access; and an output that surfaces three independent failure modes rather than a single boolean.

⚠️ What no plan will tell you: whether the three ranges are complete, or whether another configuration also manages this application's rule set β€” the silent overwrite of example 2.

πŸ”΄ What this composition does not restrict, and cannot: the IoT Central web portal and REST API. The provider hardcodes that flag off, so governs_device_traffic_only is a constant true here regardless of how the allow-list is written.

πŸ’‘ inert, locked_out and wide_open are the assertions to encode in the pipeline that applies this.

πŸ“₯ Inputs

Input Type Default Notes
iotcentral_application_id string β€” Required. The only force-new field. Anchored to Microsoft.IoTCentral/iotApps/<name> with $, case-sensitively.
default_action string "Deny" Allow | Deny, case-sensitive. πŸ”΄ Allow makes the resource a no-op.
apply_to_device bool true πŸ”’ Governs device connectivity to the IoT Hub and DPS β€” the only surface reachable from here. πŸ”΄ false makes the whole rule set inert.
ip_rule list(object(...)) [] The allow-list. πŸ”΄ Empty + Deny denies every device. ip_mask takes an IPv4 address or a CIDR block; IPv4 only. Names are unique case-insensitively and carry a mirrored charset and 128-char cap. ⚠️ 100-rule service cap, reported not enforced.
timeouts object(...) null All four operations exist. The provider budgets 30m for each write.
Full schemas
variable "iotcentral_application_id" {
  type = string
  # Anchored with $ because this rule set's own ID is IDENTICAL to the application's β€” nothing distinguishes
  # them by shape, so the anchor is what rejects a child path appended to either. CASE-SENSITIVE, because the
  # provider's generated parser compares the literal segments resourceGroups, Microsoft.IoTCentral and iotApps
  # exactly, so a lower-cased ID fails at plan anyway β€” mirroring that moves the failure to validate. See
  # example 2.
  validation {
    condition     = can(regex("^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft[.]IoTCentral/iotApps/[^/]+$", var.iotcentral_application_id))
    error_message = "iotcentral_application_id must be a full IoT Central Application Resource ID ending in /providers/Microsoft.IoTCentral/iotApps/<name> ..."
  }
}

variable "ip_rule" {
  type = list(object({
    name    = string
    ip_mask = string
  }))
  default = []
  # Duplicate NAMES are rejected, case-insensitively, because the vendor documents a rule name as a unique
  # case-insensitive string and the provider performs no de-duplication of its own β€” so what a duplicate does
  # is undefined rather than documented. But an EMPTY list is NOT rejected even though it denies every device
  # when combined with default_action = "Deny": the provider documents no minimum, ip_rule is genuinely
  # optional, and a deny-all set is a legitimate commissioning or incident posture. Rejecting it would refuse
  # legal input, so the state is emitted as `denies_all_access` instead. See example 3.
  validation {
    condition     = length(distinct([for r in var.ip_rule : lower(trimspace(r.name))])) == length(var.ip_rule)
    error_message = "ip_rule contains two entries with the same name ..."
  }
  # MIRRORS THE PROVIDER'S OWN VALIDATOR: a dotted quad with an OPTIONAL /0-32 prefix, IPv4 only. The prefix is
  # optional in the provider and the vendor documents "a single IPv4 address OR a block in CIDR notation", so a
  # bare address is accepted here. The octets are deliberately NOT range-checked, because the provider does not
  # range-check them either. See example 5.
  validation {
    condition = alltrue([
      for r in var.ip_rule :
      can(regex("^([0-9]{1,3}[.]){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$", trimspace(r.ip_mask)))
    ])
    error_message = "every ip_rule ip_mask must be an IPv4 address, optionally with a /0-32 CIDR prefix ..."
  }
  # A separate, actionable message for the one wrong shape a caller is likely to reach for: there is no correct
  # IPv6 spelling, because the service does not support IPv6 on IoT Hub or DPS at all.
  validation {
    condition     = alltrue([for r in var.ip_rule : !strcontains(trimspace(r.ip_mask), ":")])
    error_message = "an ip_rule ip_mask contains \":\", which reads as an IPv6 address. IPv6 is not supported ..."
  }
  # MIRRORS THE PROVIDER'S rule-name validator, which is borrowed from the IoT Hub service. A genuine lift, not
  # a duplicate: a provider schema validator does not fire through a module boundary. The message names `name`
  # deliberately β€” the provider's own message names a non-existent argument called `ip_rule_name`.
  validation {
    condition = alltrue([
      for r in var.ip_rule :
      can(regex("^[0-9a-zA-Z-:.+%_#*?!(),=@;']{1,128}$", r.name))
    ])
    error_message = "every ip_rule name must be 1 to 128 characters of ASCII letters and digits plus - : . + % _ # * ? ! ( ) , = @ ; ' ..."
  }
}

🧾 Outputs

Output Description Sensitive
id The Resource ID β€” identical to the application's. no
iotcentral_application_id The governed application. Force-new. no
default_action Allow or Deny. no
default_action_is_deny πŸ”’ Derived. Necessary but not sufficient. Assert true. no
applies_to_device_connectivity πŸ”’ Whether the allow-list is in force at all. Assert true. no
governs_device_traffic_only πŸ”΄ Always true. The portal and REST API are never governed. no
rule_set_is_inert πŸ”΄ Derived. true means the rule set applies to nothing. Assert false. no
permitted_cidrs Sorted allow-list. Entries may be a bare address or a CIDR block. no
permitted_cidrs_by_name Keyed by rule name. no
permitted_rule_count How many ranges. no
ip_rule_count_within_service_limit ⚠️ Derived. The 100-rule service cap. Assert true. no
denies_all_access πŸ”΄ Derived. Assert false. no
permits_entire_internet πŸ”’ Derived, IPv4 only. Assert false. no
every_rule_is_an_allow_rule ℹ️ Always true. No per-rule deny exists. no
destroy_removes_the_restriction πŸ”΄ Always true. A destroy WIDENS access. no
is_singleton_per_application Always true. no

🧠 Architecture Notes

  • The deny-everything combination is emitted, not validated, and that is a deliberate application of this suite's validation philosophy. default_action = "Deny" with an empty ip_rule list locks out every device, but the provider documents no minimum rule count, ip_rule is genuinely optional, and a deny-all set is a coherent posture while commissioning an application or containing an incident. Enforcing a minimum would reject legal input; denies_all_access makes the state assertable instead. This is the same treatment this library gives every real-in-practice, undocumented-by-the-provider at-least-one-of rule.

  • πŸ”΄ The scope of this resource is narrower than its name suggests, and that is the most important note here. The underlying Azure object has two apply-to flags β€” one for device connectivity to the IoT Hub and DPS, one for connectivity via the IoT Central web portal and REST APIs β€” and the provider hardcodes the second to false on every create and every update, exposes no argument for it, and never reads it back. So this rule set governs device traffic only, at every setting, and can never be evidence that operator or API access is restricted. Neither the schema nor the registry documentation shows this; it is visible only in the provider source, and Microsoft's IoT Central security baseline states the service-side consequence. It is emitted as the constant governs_device_traffic_only so a reader is told rather than left to infer.

  • πŸ”΄ apply_to_device = false is a switch, not a dial. With both flags false the rule set applies to no surface: default_action and every ip_rule entry are stored, returned by the API, shown in every plan, and evaluated against nothing. The provider recognises that exact combination internally as an unconfigured rule set. denies_all_access therefore takes apply_to_device into account β€” reporting a total lockout in the one state the provider itself treats as unconfigured would be precisely backwards β€” and rule_set_is_inert names the state so it can be asserted against.

  • Duplicate rule names are rejected, and the difference from the above is the point. A duplicate name is not a judgement call: Microsoft documents the name as "a unique, case-insensitive" string, and the provider does no de-duplication of its own, so what a duplicate actually does is undefined rather than documented. That is a detectable mistake with one correct answer, which is exactly what a validation block is for β€” and the check states the vendor's requirement rather than guessing at the failure mode.

  • πŸ”΄ One validation here was narrower than the provider, and widening it was the fix. An earlier revision required an explicit /32 on a single address, reasoning that "rejecting a bare address costs nothing". The provider's validator makes the prefix optional and Microsoft documents "a single IPv4 address or a block in CIDR notation", so the rule refused legal input β€” and because a failed validation {} blocks terraform destroy as well as apply, it could have left a rule set created in the portal or by CLI both unmanageable and undestroyable through this module. Widening cannot reject anything the old rule accepted, which makes it the safe direction to correct in.

  • Two constraints the schema hides are now mirrored, and one deliberately is not. The ip_rule.name charset and 128-character cap, and the IPv4-only ip_mask shape, are both real provider validators β€” and because a provider schema rule does not fire through a module boundary, mirroring them is a genuine lift from plan to the caller's own offline check rather than a duplicate. The 100-rule service cap is not mirrored: Microsoft documents it as raisable by support request, so enforcing it would refuse a supported configuration. It is reported through ip_rule_count_within_service_limit. A provider bound and a service bound are different things, and only one of them belongs in a validation {}.

  • The three failure modes are independent, so they get separate outputs. rule_set_is_inert is "no control at all"; denies_all_access is an availability failure; permits_entire_internet is an exposure failure. A single "posture is wrong" boolean would conflate three different incidents, and no two of the three imply the third.

  • permits_entire_internet checks the IPv4 form only, and that is complete rather than partial. The provider's validator has no IPv6 form at all and Microsoft states that IPv6 is not supported on IoT Hub or DPS, so ::/0 cannot reach this resource β€” a check for it would be dead code advertising a protection that has nothing to protect against.

  • A locked-out fleet is hard to diagnose, which is why the availability output exists. Microsoft documents that a connection from an address that is not explicitly allowed receives an unauthorized 401 whose message does not mention the IP rule β€” so the first instinct is to suspect credentials and rotate keys, which cannot fix it. That is the reason the flag is emitted positively as applies_to_device_connectivity rather than left as a raw boolean.

  • ip_rule is rendered from a canonical map keyed on the lowercased rule name, so the emitted block order does not depend on the caller's list order and a reordering produces no diff. Rejecting duplicate names is what makes that key safe β€” the two decisions depend on each other.

  • The singleton nature is emitted as a constant true because it is silent. Azure identifies the rule set by its application, so two configurations managing one application's policy produce no name collision and no plan warning β€” just a policy that changes depending on which pipeline ran last.

  • πŸ”΄ The destroy semantics are documented as a widening, and there is no delete call at all. The provider reads the application, sets its network rule set to nothing, and PUTs the whole application model back β€” so a destroy is an update to the application wearing a destroy's clothes. Most destroys in this library remove something; this one removes a restriction, so unless the application's own public_network_access_enabled is false, devices may connect from anywhere afterwards. It is emitted as the constant destroy_removes_the_restriction, and it is also the argument for the sibling module's default.

  • ⚠️ The whole-application PUT has two further consequences worth knowing. First, there is no rule-set sub-resource to scope an RBAC role to, so the grant that permits editing this allow-list also permits rewriting the application's display name, subdomain, template and public network access. Second, only the create path takes a lock: an update or a delete performs an unlocked read-modify-write of the entire application model, so a composition that edits the application and its rule set concurrently can have one write overwrite the other's fields. The dependency edge from iotcentral_application_id orders create and destroy; it does not order two in-place updates. It is also why all three write timeouts are budgeted at 30 minutes.

  • ⚠️ Two adoption behaviours are counter-intuitive. A first apply against a never-configured application raises no "already exists" error and simply takes ownership, because the provider's duplicate-detection guard fires only when the existing rule set is not the allow-all triple β€” every application ships with a rule set already attached. And a rule set cleared out of band makes plan fail rather than propose a re-create, because the read returns an error instead of marking the resource gone.

  • The universal tags tail is omitted, having been checked rather than assumed. The resource exposes no tags; policy attached to an application is not independently ownable.

  • The RBAC section frames this as change control rather than access control, because no Azure role distinguishes "add one office range" from "permit the entire internet". The useful gate is a plan review of two named outputs.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Unmatched device traffic default_action = "Deny" β€” an allow-list "Allow", which makes the resource a no-op
An allow-list that governs nothing apply_to_device = true, the only setting under which it is evaluated set false, deliberately and recorded β€” it switches the list off
Accidental total lockout denies_all_access emitted, and it accounts for apply_to_device β€”
A rule set silently applying to no surface rule_set_is_inert emitted β€”
Whole-internet entries permits_entire_internet (IPv4, which is the only reachable form) supply 0.0.0.0/0, knowingly
An undefined duplicate-name outcome duplicate names rejected, case-insensitively β€”
Order-dependent diffs blocks rendered from a canonical keyed map β€”
Two owners, silent overwrite is_singleton_per_application emitted β€”
A destroy that reopens access destroy_removes_the_restriction emitted β€”
Believing the portal is governed governs_device_traffic_only emitted β€”
Wrong-resource IDs anchored to the exact type with $, case-sensitively β€”
Names the provider will refuse at plan charset and 128-char cap mirrored offline β€”
Negated security fields default_action_is_deny, applies_to_device_connectivity, governs_device_traffic_only β€”
Invented tags omitted, because the schema has none β€”
Over-rejecting legal input an empty allow-list, a bare IPv4 address, an out-of-range octet and a 101st rule are all permitted, and reported where it matters β€”
  • Before adopting: confirm exactly one configuration manages this application's rule set.
  • Before applying an empty allow-list: it stops every device, and Azure will not warn you.
  • Before setting Allow: the resource stops restricting anything. The fix is usually a missing range.
  • Before setting apply_to_device = false: record why. It switches the allow-list off rather than narrowing it.
  • Before relying on this to restrict operators or the REST API: it cannot, at any setting.
  • Before destroying: this widens access, it does not narrow it.
  • Before importing: plan and read it, or the first apply may remove ten live ranges.
  • Before passing 100 rules: the service caps the filter there, and nothing offline will stop you.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag β€” ?ref=v1.0.0 β€” never a branch.
  • Plan-only from here. A human applies from CI.
  • βœ… default_action, apply_to_device and the whole ip_rule list update in place. Revising is cheap.
  • πŸ”΄ Only iotcentral_application_id is force-new.
  • ⚠️ No propagation time is documented for a rule change β€” treat the window as unknown and non-zero, and sequence a re-addressing exercise as add-then-remove rather than as an edit.
  • πŸ”΄ A destroy widens access and issues no delete call, reverting to the application's own public-access setting. Use a CanNotDelete lock on the application where that matters.
  • ⚠️ Import takes the application's ID, then plan before applying. A first apply against a never-configured application takes ownership silently, with no "already exists" error.
  • ⚠️ terraform validate from this folder evaluates no variables, so none of the seven validation {} blocks fires here. It proves type-correctness. Use terraform console to exercise the conditions.
  • πŸ’‘ Print rule_set_is_inert, denies_all_access and permits_entire_internet in the pipeline.

πŸ§ͺ Testing

Three commands disagree about which of this module's validation {} blocks fire, so it is worth being precise about which one proves what.

Command Which of the seven conditions fire
terraform validate inside this folder none β€” no variables are evaluated
terraform validate on a calling configuration all seven, since every condition reads only its own variable
terraform console -no-color with an immediate end-of-input all seven

So terraform validate and terraform fmt -check prove type-correctness, HCL validity, that the module declares no provider block, and that no output is sensitive and no secret is accepted. The variable rules are proved with terraform console, driven from a deliberately bad .tfvars. They confirm:

  • iotcentral_application_id is an IoT Central Application Resource ID anchored to Microsoft.IoTCentral/iotApps/<name> with $, and correctly cased β€” a lower-cased /resourcegroups/ is refused, matching the provider's own case-sensitive parser;
  • default_action is exactly Allow or Deny, case-sensitively;
  • every ip_rule entry has a non-empty name, within the provider's charset and 128-character cap;
  • no two ip_rule entries share a name, case-insensitively;
  • every ip_mask is an IPv4 address with an optional /0-32 prefix, with an IPv6 value refused by name.

And the cases that must pass, driven as explicitly as the failures β€” because a widening is only real if the widened value is accepted:

  • a bare 203.0.113.5 with no prefix, mixed freely with prefixed entries;
  • 0.0.0.0/0, which is legal and reported rather than refused;
  • a 128-character name, and a name using every legal special character in the mirrored class;
  • 256.0.0.1 β€” an out-of-range octet the provider does not check either, deliberately left to Azure;
  • an empty ip_rule list, which is legal and reported by denies_all_access;
  • apply_to_device = false and default_action = "Allow", both legal and both reported;
  • Go duration timeouts including 1h30m, 1.5h, 0 and 500ms.

πŸ’‘ The pass cases matter as much as the failures. A harness of failures alone cannot distinguish a condition that correctly rejects bad input from one that rejects everything β€” and a boring valid baseline is what catches a condition that raises rather than fails.

What only plan and apply exercise:

  • whether the application exists and the caller may modify it;
  • the provider's own validators, which do not fire through a module boundary β€” which is exactly why the charset and shape rules above are mirrored here rather than left to the provider;
  • πŸ”΄ the 100-rule service cap and out-of-range octets, both of which Azure rejects at apply and this module deliberately reports rather than refuses.

What no Terraform command checks at any stage:

  • πŸ”΄ whether another configuration manages the same application's rule set β€” the silent overwrite;
  • πŸ”΄ whether the allow-list is complete, which is indistinguishable from a device fault when it is not;
  • πŸ”΄ whether the application's own public_network_access_enabled is false, which decides whether this allow-list is the primary control or a second layer;
  • what the devices' real egress ranges are β€” the hard part of this resource.

πŸ’¬ Example Output

Outputs:

applies_to_device_connectivity      = true
default_action                     = "Deny"
default_action_is_deny             = true
denies_all_access                  = false
destroy_removes_the_restriction    = true
every_rule_is_an_allow_rule        = true
governs_device_traffic_only        = true
id                                 = "/subscriptions/00000000-.../providers/Microsoft.IoTCentral/iotApps/iotc-plant-telemetry"
iotcentral_application_id          = "/subscriptions/00000000-.../providers/Microsoft.IoTCentral/iotApps/iotc-plant-telemetry"
ip_rule_count_within_service_limit = true
is_singleton_per_application       = true
permits_entire_internet            = false
permitted_cidrs                    = [
  "198.51.100.16/28",
  "198.51.100.32/28",
  "198.51.100.7",
]
permitted_cidrs_by_name            = {
  "gateway-uk" = "198.51.100.7"
  "plant-de"   = "198.51.100.32/28"
  "plant-uk"   = "198.51.100.16/28"
}
permitted_rule_count               = 3
rule_set_is_inert                  = false

πŸ”΄ rule_set_is_inert, denies_all_access and permits_entire_internet are all false β€” the three assertions, all healthy, and all three are needed (example 9).

πŸ”΄ governs_device_traffic_only = true is a scope statement, not a health check. It is always true, and it says the IoT Central web portal and REST API are not governed here at any setting.

⚠️ id and iotcentral_application_id are identical. That is not a copy-paste error in this document β€” it is the singleton behaviour of example 2, and the reason is_singleton_per_application is emitted.

βœ… permitted_cidrs is sorted, so it diffs cleanly against another environment's output (example 6). Note 198.51.100.7 carries no prefix, which is legal: the CIDR prefix is optional (example 5).

ℹ️ No tags output, because the resource has none (example 7).

πŸ” Troubleshooting

Symptom Cause Fix
Devices get 401 Unauthorized and the message says nothing about an IP rule That is exactly what a blocked address receives β€” Microsoft documents the response as not mentioning the rule. Check denies_all_access and permitted_cidrs before rotating any credential (example 3).
Every device stopped reporting after an apply Deny with an empty or incomplete ip_rule. Check denies_all_access (example 3).
The allow-list is right, denies_all_access is false, and nothing is being restricted apply_to_device = false, so the whole rule set is inert. Check rule_set_is_inert; set apply_to_device = true (example 3).
Restricted the rule set but operators still reach the portal and REST API Expected and unavoidable. The provider hardcodes the portal-and-API flag to false; this resource governs device traffic only. Use Conditional Access on the identities, not this resource (example 1).
The rule set restricts nothing default_action = "Allow". Use Deny and add the missing range (example 3).
The allow-list looks right but is ignored A rule permits 0.0.0.0/0. Check permits_entire_internet (example 5).
A range you added is not permitted Two rules shared a name. Microsoft requires names to be unique case-insensitively and the provider de-duplicates nothing, so the outcome is undefined. Duplicates are now rejected offline (example 5).
Wanted to permit a range but deny one host inside it There is no per-rule deny. The Azure object's per-rule action field is not exposed and its only value is Allow. Narrow the ranges instead (example 5).
Apply fails on the 101st rule after a clean plan The service caps the IP filter at 100 rules and the schema carries no maximum. Consolidate the ranges, or ask Azure support to raise the cap. Check ip_rule_count_within_service_limit (example 5).
Apply fails on an ip_mask that validated fine An octet above 255. Neither the provider nor this module range-checks them. Correct the address (example 5).
Validation rejects an IPv6 ip_mask IPv6 is not supported on IoT Hub or DPS, and the provider's validator is IPv4-only. There is no IPv6 form; supply the IPv4 range (example 5).
Validation rejects an ip_rule name It falls outside the provider's charset or exceeds 128 characters. Use ASCII letters and digits plus - : . + % _ # * ? ! ( ) , = @ ; ' (example 5).
Validation rejects default_action Lowercase "deny". The values are case-sensitive (example 3).
Validation rejects iotcentral_application_id Not an iotApps ID, a child path, or mis-cased β€” the provider parses the segments case-sensitively. Pass module.<app>.id (example 2).
The policy keeps changing on its own Two configurations manage the same rule set. Only one may own it (example 2).
Devices connect from anywhere unexpectedly apply_to_device = false, which switches the allow-list off rather than narrowing it. Set it true, or record why not (example 5).
A first apply took over a live rule set with no "already exists" error The provider's guard fires only when the existing rule set is not allow-all, and every application ships with one attached. Import and plan before applying (example 8).
An import removed all the live ranges The configuration had an empty ip_rule. Plan after importing, before applying (example 8).
plan fails with a read error instead of proposing a re-create The rule set was cleared out of band; the read errors rather than marking the resource gone. Re-import the application's ID (example 8).
A destroy made the app more reachable There is no delete call β€” the provider clears the rule set out of the application, so the restriction is gone. Check the app's public-access setting; use a CanNotDelete lock (example 10).
A rule-set edit appeared to overwrite the application's own fields Every write is a PUT of the whole application, and only create takes a lock. Do not apply an application change and a rule-set change concurrently (example 6).
Wanted a tags input The resource has none. Tag the application (example 7).
Wanted prevent_destroy lifecycle is not valid inside a module block. Use a CanNotDelete lock on the application.

πŸ”— Related Docs

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