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). Targetshashicorp/azurerm ~> 4.0.
- π‘οΈ 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
falseon 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.
- π΄
Denywith an emptyip_rulelist denies every device, whileapply_to_deviceistrue. 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 = falseswitches the allow-list OFF rather than narrowing it. Both apply-to flags are then false and the whole rule set applies to nothing β emitted asrule_set_is_inert. - π΄ A
terraform destroyWIDENS 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"makesip_rulean allow-list, andapply_to_device = trueis the only setting under which the allow-list is evaluated. β οΈ ip_masktakes 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 Unauthorizedthat 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.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub.
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
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. |
| 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
falseon 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 = falseswitches 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 destroyWIDENS 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
planFAIL, 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 importtakes 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/ormicrosoft.iotcentralis refused. This module mirrors that so the failure lands atvalidate.- π΄
default_action = "Deny"plus an emptyip_rulelist denies every device, whileapply_to_deviceistrue. 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_masktakes 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.1satisfies its regex and reaches Azure.β οΈ ip_rule.namecarries 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 calledip_rule_name.β οΈ ip_rulenames 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 bare401 Unauthorizedwhose message does not mention the IP rule.β οΈ It interacts with the application's ownpublic_network_access_enabled, which the sibling application module defaults tofalse. 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_idis force-new. There is noCustomizeDiffon this resource at all, so there is no conditional or one-directional force-new to discover. - No
tagsattribute exists. lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy. Use aCanNotDeletemanagement lock on the application.
| 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
Getand populates three non-secret fields; nothing in this resource's API surface lists keys, and no output is sensitive.
- An existing IoT Central Application, passed as an attribute reference (example 2).
- The
Microsoft.IoTCentralresource 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.
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
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_rulelist here would deny every device. Read example 3 before applying one.
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 |
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.
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
falseon every create and every update β no argument, never read back, no setting ofapply_to_devicechanges 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_deviceexists 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 ownpublic_network_access_enabled, which the sibling module defaults tofalse. 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_applicationis emitted as a constanttrueto 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_ruleis 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 = trueis 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_accessisfalseandpermitted_cidrslooks healthy β because nothing is being denied at all. Both apply-to flags are then false, so the whole rule set applies to no surface. Assertrule_set_is_inert = falsealongsidedenies_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 missingip_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
Contributorto 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_accessandpermits_entire_internetin 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
Readeron 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_masktakes a single IPv4 address OR a CIDR block, and the prefix is optional. The provider's validator makes the/bitsgroup 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 blocksterraform destroyas 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.1satisfies 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.
β οΈ nameis 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 calledip_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 throughip_rule_count_within_service_limitrather 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 fromdefault_actionalone.
π΄ Devices are the harder list, and
apply_to_device = truemeans they need one. Fixed sites have stable egress ranges; devices on mobile networks or consumer broadband do not. If you genuinely cannot enumerate them, settingapply_to_device = falseis a legitimate decision β but make it explicitly and record it, becauseapplies_to_device_connectivitywill reportfalseand a reviewer will ask.
π
0.0.0.0/0in the list defeats the whole resource and is much harder to spot thandefault_action = "Allow"β it hides as one plausible-looking entry.permits_entire_internetchecks for it. Only the IPv4 form is checked, and that is complete rather than partial:::/0cannot 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_idis force-new.default_action,apply_to_deviceand the entireip_rulecollection 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_cidrsis 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_nameis 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
tagstail 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 reasondenies_all_accessis 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_ruleand 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 firstapplyagainst 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 breaksplanrather 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_outandwide_openare the three assertions that matter, and they fail in three different directions β no control at all, an outage, and an exposure. All three arefalsein a healthy configuration, and no two of them substitute for the third.
β
deny_firstshould betrue.falsemeansdefault_action = "Allow"and the rest of this output is decorative.
π΄
inert = trueis the one that reads healthiest while being worst. Withapply_to_device = falsethe allow-list is stored, shown in every plan and applied to nothing βlocked_outisfalseandrangeslooks right, because nothing is being evaluated. Assert it explicitly; nothing else in this output implies it.
π΄
devices_onlyis a constanttrue, 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.
π΄
singletonis a constanttrueβ read it as "and confirm no other configuration manages this application's rule set" (example 2). It cannot be checked from here.
β οΈ in_limitis 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.
rangesbeing plausible is not the same as it being complete, and an incomplete allow-list looks exactly like a device fault β the device sees a bare401, 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 validaterun from inside this folder proves type-correctness and nothing about the variable validations β it evaluates no variables, so none of the sevenvalidation {}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_idis an IoT Central Application Resource ID anchored to that exact type and correctly cased;default_actionisAlloworDeny, case-sensitively; everyip_rulename is non-empty, unique case-insensitively, and within the provider's charset and 128-character cap; and everyip_maskis an IPv4 address with an optional/0-32prefix, with an IPv6 value refused by name.
π‘ Where each command actually fires them.
terraform console -no-colorwith 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. Aterraform validateon 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. Onlyvalidatefrom inside the module's own directory fires none.
β Proved that way, not asserted. Two rules named
hqandHQfire the duplicate check;"deny"in lowercase fires the enum;name = "hq egress"fires the charset mirror;2001:db8::/32fires 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 bare203.0.113.5, a0.0.0.0/0, a 128-character name, every legal special character in the charset, and a256.0.0.1that 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_enabledtofalseβ 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_enabledand this one emitsdefault_action_is_denyandpermitted_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_actionandapply_to_devicestated 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_onlyis a constanttruehere regardless of how the allow-list is written.
π‘
inert,locked_outandwide_openare the assertions to encode in the pipeline that applies this.
| 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. |
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 - : . + % _ # * ? ! ( ) , = @ ; ' ..."
}
}| 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 |
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 |
-
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 emptyip_rulelist locks out every device, but the provider documents no minimum rule count,ip_ruleis 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_accessmakes 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
falseon 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 constantgoverns_device_traffic_onlyso a reader is told rather than left to infer. -
π΄
apply_to_device = falseis a switch, not a dial. With both flags false the rule set applies to no surface:default_actionand everyip_ruleentry 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_accesstherefore takesapply_to_deviceinto account β reporting a total lockout in the one state the provider itself treats as unconfigured would be precisely backwards β andrule_set_is_inertnames 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
validationblock 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
/32on 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 failedvalidation {}blocksterraform destroyas 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.namecharset and 128-character cap, and the IPv4-onlyip_maskshape, 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 throughip_rule_count_within_service_limit. A provider bound and a service bound are different things, and only one of them belongs in avalidation {}. -
The three failure modes are independent, so they get separate outputs.
rule_set_is_inertis "no control at all";denies_all_accessis an availability failure;permits_entire_internetis 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_internetchecks 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::/0cannot 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
401whose 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 asapplies_to_device_connectivityrather than left as a raw boolean. -
ip_ruleis 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
truebecause 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_enabledisfalse, devices may connect from anywhere afterwards. It is emitted as the constantdestroy_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 fromiotcentral_application_idorders 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 makesplanfail rather than propose a re-create, because the read returns an error instead of marking the resource gone. -
The universal
tagstail is omitted, having been checked rather than assumed. The resource exposes notags; 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.
| 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.
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_deviceand the wholeip_rulelist update in place. Revising is cheap. - π΄ Only
iotcentral_application_idis 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
CanNotDeletelock 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 validatefrom this folder evaluates no variables, so none of the sevenvalidation {}blocks fires here. It proves type-correctness. Useterraform consoleto exercise the conditions.- π‘ Print
rule_set_is_inert,denies_all_accessandpermits_entire_internetin the pipeline.
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_idis an IoT Central Application Resource ID anchored toMicrosoft.IoTCentral/iotApps/<name>with$, and correctly cased β a lower-cased/resourcegroups/is refused, matching the provider's own case-sensitive parser;default_actionis exactlyAlloworDeny, case-sensitively;- every
ip_ruleentry has a non-emptyname, within the provider's charset and 128-character cap; - no two
ip_ruleentries share a name, case-insensitively; - every
ip_maskis an IPv4 address with an optional/0-32prefix, 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.5with 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_rulelist, which is legal and reported bydenies_all_access; apply_to_device = falseanddefault_action = "Allow", both legal and both reported;- Go duration timeouts including
1h30m,1.5h,0and500ms.
π‘ 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_enabledisfalse, 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.
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_accessandpermits_entire_internetare allfalseβ the three assertions, all healthy, and all three are needed (example 9).
π΄
governs_device_traffic_only = trueis a scope statement, not a health check. It is alwaystrue, and it says the IoT Central web portal and REST API are not governed here at any setting.
β οΈ idandiotcentral_application_idare identical. That is not a copy-paste error in this document β it is the singleton behaviour of example 2, and the reasonis_singleton_per_applicationis emitted.
β
permitted_cidrsis sorted, so it diffs cleanly against another environment's output (example 6). Note198.51.100.7carries no prefix, which is legal: the CIDR prefix is optional (example 5).
βΉοΈ No
tagsoutput, because the resource has none (example 7).
| 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. |
azurerm_iotcentral_application_network_rule_setβ provider documentation.azurerm_iotcentral_applicationβ the application, and its ownpublic_network_access_enabledof example 11.- Restrict public access to an IoT Central application β the IoT Central side of this allow-list, including the warning to list the proxy's address where devices connect through one (examples 1 and 5).
- IoT Hub IP filtering β the underlying filter this rule set writes: the address formats, the unordered allow-rule semantics, the
401a blocked device receives, and the empty-list-blocks-everything default. - IoT Hub IP address limitations and DPS best practices β the 100-rule cap on each surface, and the statement that IPv6 is not supported on either.
- Azure security baseline for IoT Central β NS-2, which states that restricted connectivity covers device connections to the underlying hubs and DPS while the web UI and APIs continue to work through their public endpoints.
- IoT Central application security guide β where this rule set sits among the other controls, and the user, API-token and Entra controls that govern the surfaces this one cannot.
- Sibling modules:
terraform-azurerm-iotcentral-application,terraform-azurerm-iotcentral-organization,terraform-azurerm-resource-group,terraform-azurerm-role-assignments. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."