Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure IoT Hub File Upload Terraform Module

Configures the storage account, blob container, authentication and notification behaviour that back an IoT Hub's device file-upload feature, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • πŸ“¦ Points an IoT Hub at the storage account and blob container that receive device file uploads.
  • πŸ” Selects how the hub authenticates to that storage account β€” account key, or a managed identity.
  • πŸ”” Configures the optional file-upload notification queue: lifetime, lock duration, delivery attempts.
  • ⏱️ Bounds the SAS URI lifetime handed to each uploading device.
  • 🧾 Emits the configuration as data plus a set of derived flags for the traps that no plan reveals.

πŸ’‘ Why it matters: file upload is the one IoT Hub feature where a storage account key is unavoidable, where terraform destroy does not undo what was applied, and where rotating that key produces no diff. This module cannot remove those properties β€” the provider and the platform decide them β€” so it names each one in an output rather than letting it be discovered in production.


❀️ Support this project

If this module saves you time, a little support goes a long way:


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

Every module in the IoT Hub family hangs off one hub. This module is the ...-iothub-file-upload node β€” and note it is the only one joining the hub by Resource ID rather than by name.

flowchart TB
  RG["terraform-azurerm-resource-group"]
  HUB["terraform-azurerm-iothub"]
  SA["terraform-azurerm-storage-account"]
  UAI["terraform-azurerm-user-assigned-identity"]
  RA["terraform-azurerm-role-assignments"]
  EPEH["terraform-azurerm-iothub-endpoint-eventhub"]
  ROUTE["terraform-azurerm-iothub-route"]
  FBROUTE["terraform-azurerm-iothub-fallback-route"]
  ENRICH["terraform-azurerm-iothub-enrichment"]
  FUPLOAD["terraform-azurerm-iothub-file-upload"]

  RG -->|"name feeds resource_group_name"| HUB
  UAI -->|"id feeds the hubs identity"| HUB
  HUB -->|"name feeds iothub_name"| ROUTE
  HUB -->|"name feeds iothub_name"| FBROUTE
  HUB -->|"name feeds iothub_name"| ENRICH
  HUB -->|"id feeds iothub_id, an ID not a name"| FUPLOAD
  EPEH -->|"name feeds endpoint_names"| ROUTE
  EPEH -->|"name feeds endpoint_names"| ENRICH
  SA -->|"container name and connection string"| FUPLOAD
  RA -->|"Storage Blob Data Contributor on the account"| FUPLOAD
  HUB -.->|"its inline inputs conflict with all four"| ENRICH

  classDef batch fill:#0078D4,stroke:#004578,color:#ffffff
  classDef anchor fill:#004578,stroke:#002d4d,color:#ffffff
  classDef ext fill:#f0f3f7,stroke:#9aa7b5,color:#1b2733
  class ENRICH,FUPLOAD batch
  class HUB anchor
  class RG,SA,UAI,RA,EPEH,ROUTE,FBROUTE ext
Loading

🧬 What this module builds

flowchart TB
  PARENT["iothub_id<br/>a RESOURCE ID, force new, and the only one that is"]
  SECRET["connection_string<br/>required, sensitive, a storage account key"]
  CONT["container_name"]
  AUTH["authentication_type<br/>identity_id"]
  NOTIF["notifications_enabled<br/>default_ttl, lock_duration, max_delivery_count"]
  SAS["sas_ttl<br/>the device leg, never inert"]
  T["azurerm_iothub_file_upload.this"]
  OUT["id, which IS the hubs own id<br/>iothub_name<br/>uses_identity_based_authentication"]
  SILENT["destroy_leaves_the_configuration_live_on_the_hub<br/>a_rotated_storage_key_produces_no_plan_diff"]
  CRED["plan_access_is_credential_access<br/>identity_based_authentication_does_not_remove_the_storage_key"]
  INERT["notification_settings_configured_but_inert"]

  PARENT --> T
  SECRET --> T
  CONT --> T
  AUTH --> T
  NOTIF --> T
  SAS --> T
  T --> OUT
  T --> SILENT
  SECRET --> CRED
  AUTH --> CRED
  NOTIF --> INERT

  classDef keystone fill:#004578,stroke:#002d4d,color:#ffffff
  classDef io fill:#0078D4,stroke:#004578,color:#ffffff
  classDef note fill:#f0f3f7,stroke:#9aa7b5,color:#1b2733
  class T keystone
  class PARENT,SECRET,CONT,AUTH,NOTIF,SAS,OUT io
  class SILENT,CRED,INERT note
Loading
Resource Cardinality Notes
azurerm_iothub_file_upload.this exactly one per hub A facet of the hub, not a child collection. Its Resource ID is the hub's.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures the provider, including the mandatory features {} block.

Schema notes that bite β€” each verified against the live provider schema and source:

  • πŸ”΄ The record's Resource ID is the hub's Resource ID, unchanged. There is no /fileUpload segment. Two blocks against one hub collide on one object with nothing to conflict on, and the plan never settles.
  • πŸ”΄ terraform destroy is a no-op against Azure. The delete path reads the hub and writes it back unmodified; the file-upload configuration stays live.
  • πŸ”΄ A rotated storage key produces no plan diff. Azure masks the key and the provider suppresses the diff on that portion.
  • πŸ”΄ connection_string is required even when authentication_type is identityBased. Devices always receive a SAS URI generated from it.
  • ⚠️ identity_id may only be set with identityBased β€” enforced by the provider at apply time, and by this module at parse time.
  • ⚠️ The first-apply guard is conditional. It is skipped by a provider feature flag, and does not trip on a half-configured hub.
  • ⚠️ iothub_id is the only force-new argument. Everything else updates in place.
  • ⚠️ No tags, no location.

πŸ”‘ Required Azure RBAC Roles / Permissions

Principal Permission Scope Why
The Terraform identity Contributor, or a custom role with Microsoft.Devices/iotHubs/write The IoT Hub File upload is a property of the hub, so configuring it is a write to the hub
The Terraform identity Microsoft.Devices/iotHubs/read The IoT Hub Refresh, plan, and the first-apply guard
The hub's managed identity Storage Blob Data Contributor The storage account Only when authentication_type is identityBased

πŸ”΄ None of IoT Hub's four built-in roles grants the management-plane write. IoT Hub Data Contributor, Data Reader, Registry Contributor and Twin Contributor are all data-plane β€” Microsoft states they "grant access to resources like devices and twin but they don't grant access to the IoT Hub resource".

πŸ”΄ Microsoft is explicit about which storage role to use: "select Storage Blob Data Contributor. (Don't select Contributor or Storage Account Contributor.)" The broader roles do not grant the blob data-plane access the hub actually needs.

πŸ”΄ Plan access is credential access here. connection_string is a required input carrying a storage account key. It sits in state in plaintext; sensitive = true redacts plan output and does not encrypt state. Grant plan rights only to principals you would hand the storage key to, and keep state in an encrypted, access-controlled backend β€” never a local file in a repository.

⚠️ The permission is broader than the operation. Microsoft.Devices/iotHubs/write is hub-level, so the same grant also permits changing the hub's SKU, routing, network rules and identity.


Azure Prerequisites

  • Microsoft.Devices registered on the subscription.
  • An existing IoT Hub, referenced by its Resource ID.
  • πŸ”΄ That hub's file_upload input left unset. Choose one model per hub.
  • An existing storage account and blob container. This module creates neither.
  • πŸ”΄ The storage account in the same subscription as the hub. Microsoft documents this requirement; nothing here can check it.
  • πŸ”΄ For identityBased: the hub's identity already holding Storage Blob Data Contributor on the storage account, assigned before the first apply, with a few minutes allowed for propagation.
  • No resource group or region to choose for this record.

πŸ“ Module Structure

terraform-azurerm-iothub-file-upload/
β”œβ”€β”€ providers.tf    # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 11 typed inputs, 14 validations
β”œβ”€β”€ main.tf         # derived locals + the single azurerm_iothub_file_upload.this
β”œβ”€β”€ outputs.tf      # 29 outputs: configuration, derived flags, and the traps
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string # provisioned out of band
  container_name    = "device-uploads"
}

πŸ”’ The caller configures provider "azurerm" { features {} }, authentication, and the state backend. connection_string carries a storage account key β€” pass a reference, never a literal.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
iothub_id string (Resource ID) terraform-azurerm-iothub β†’ id
container_name string terraform-azurerm-storage-container β†’ name
identity_id string (Resource ID) terraform-azurerm-user-assigned-identity β†’ id
connection_string string (sensitive) provisioned out of band β€” never emitted by a module here

Emits

Output Description Consumed by
id Resource ID β€” the hub's own ID Audit
iothub_name Hub name, derived from the ID The routing modules, which join by name
uses_identity_based_authentication Whether the hub-to-storage leg uses an identity check blocks
plan_access_is_credential_access Constant true Security review

πŸ“š Example Library

1 Β· Minimal, key-based

The smallest call. authentication_type defaults to keyBased, notifications are off, and the SAS URI lives one hour.

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"
}

πŸ”’ The empty call is not the hardened call here, and this module does not pretend otherwise: connection_string is required by the provider, so there is no configuration of this resource that does not put a storage account key in state.

2 Β· Identity-based, user-assigned identity

The preferred posture where the prerequisites are met.

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"

  authentication_type = "identityBased"
  identity_id         = module.upload_identity.id
}

⚠️ The identity must be one of the hub's own identity_ids, and must already hold Storage Blob Data Contributor on the storage account.

3 Β· Identity-based with the hub's system-assigned identity

Omitting identity_id under identityBased is documented behaviour, not an oversight β€” Azure falls back to the hub's system-assigned identity.

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"

  authentication_type = "identityBased"
}

output "using_system_identity" {
  value = module.file_upload.uses_the_hubs_system_assigned_identity
}

ℹ️ uses_the_hubs_system_assigned_identity exists so this reads as a decision in the plan rather than as a missing field.

4 Β· Enabling upload notifications

Notifications are off by default. Turning them on activates the three queue settings.

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"

  notifications_enabled = true
  default_ttl           = "P1D"
  lock_duration         = "PT30S"
  max_delivery_count    = 25
}

πŸ’‘ default_ttl = "P1D" here matches what the Azure portal shows as the default. The provider's default is PT1H β€” see example 5.

5 Β· Catching settings that cannot take effect

Tuning the notification queue while notifications are disabled is legal, shows up in the plan, and does nothing.

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"

  max_delivery_count = 25 # notifications_enabled is still false
}

check "notification_settings_take_effect" {
  assert {
    condition     = !module.file_upload.notification_settings_configured_but_inert
    error_message = "Notification settings were tuned but notifications_enabled is false, so they do nothing."
  }
}

⚠️ Nothing rejects this combination. The flag is the only signal.

6 Β· Bounding the SAS URI lifetime

sas_ttl is the one setting here that is never inert, because the device leg is always SAS.

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"

  sas_ttl = "PT15M"
}

πŸ”’ This bounds how long a leaked upload URI stays usable. It applies whether or not the hub itself uses a managed identity.

7 Β· Asserting the security posture
check "file_upload_uses_identity" {
  assert {
    condition     = module.file_upload.uses_identity_based_authentication
    error_message = "Hub-to-storage authentication must use a managed identity."
  }
}

check "sas_uri_is_short_lived" {
  assert {
    condition     = contains(["PT15M", "PT30M"], module.file_upload.sas_ttl)
    error_message = "SAS URIs must expire within 30 minutes."
  }
}

⚠️ Passing the first assertion does not mean the storage key is gone β€” see example 8.

8 Β· The limits of identity-based authentication

Worth surfacing in review, because the setting reads stronger than it is.

output "storage_key_still_present" {
  value = module.file_upload.identity_based_authentication_does_not_remove_the_storage_key
}

output "plan_access_is_credential_access" {
  value = module.file_upload.plan_access_is_credential_access
}

πŸ”’ authentication_type governs the hub-to-storage leg only. Devices always upload with a SAS URI that Azure generates from connection_string, so the account key stays in the connection string, in state, and in the device path regardless.

9 Β· Key rotation is invisible to Terraform
output "rotation_needs_its_own_step" {
  value = module.file_upload.a_rotated_storage_key_produces_no_plan_diff
}

⚠️ Azure returns the account key masked and the provider compares the connection string with that portion excluded. After rotating the storage key, terraform plan reports no changes β€” the hub keeps using the old key until something writes the endpoint again. Treat rotation as an out-of-band runbook step with a forced re-apply, not as drift a plan will catch.

10 Β· Destroy does not turn file upload off
output "destroy_is_a_no_op" {
  value = module.file_upload.destroy_leaves_the_configuration_live_on_the_hub
}

πŸ”΄ The provider's delete path reads the hub and writes it back unmodified. terraform destroy removes the record from state while the storage endpoint, connection string and notification flag stay live on the hub. To actually disable file upload, clear it on the hub itself. lifecycle blocks are not valid inside a module block, so a caller cannot add prevent_destroy here β€” where deletion must be prevented, use a CanNotDelete management lock on the hub, remembering a lock prevents deletion rather than replacement.

11 Β· One per hub, and why
output "id_is_the_hub_id" {
  value = module.file_upload.id == module.iothub.id # true
}

πŸ”΄ The record's Resource ID is the hub's. A second block against the same hub produces the same ID, leaving Terraform with one object claimed twice and nothing to conflict on β€” an alternating diff that never converges rather than an error. only_one_file_upload_configuration_per_hub states the rule; there is nothing to key a for_each on.

12 Β· Choosing the model: this module or the hub's inline input

terraform-azurerm-iothub exposes a file_upload input that does the same job. Using both against one hub causes spurious changes on every plan, and no validation can detect it, because the two halves live in different module blocks.

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

  name                = "iot-plant-eastus"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
  sku                 = { name = "S1", capacity = 1 }

  file_upload = null # managed by the module below instead
}

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"
}

ℹ️ Setting file_upload = null explicitly makes the chosen model visible in review rather than implied by omission.

13 Β· πŸ—οΈ End-to-end composition

A hub with an identity, a storage container to receive uploads, the role assignment the identity needs, file upload wired to both, and a route so telemetry has somewhere to go.

module "resource_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-iot-eastus"
  location = "eastus"
}

module "upload_identity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"

  name                = "uai-iot-uploads"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
}

module "storage" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stiotuploadseastus"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
}

# The identity needs blob DATA access - not Contributor, not Storage Account Contributor.
module "upload_role" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.storage.id

  role_assignments = {
    "hub-to-uploads" = {
      role_definition_name = "Storage Blob Data Contributor"
      principal_id         = module.upload_identity.principal_id
    }
  }
}

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

  name                = "iot-plant-eastus"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
  sku                 = { name = "S1", capacity = 1 }

  identity = {
    type         = "UserAssigned"
    identity_ids = [module.upload_identity.id]
  }

  file_upload = null # this composition uses the dedicated module below
}

module "file_upload" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"

  iothub_id         = module.iothub.id
  connection_string = var.storage_connection_string
  container_name    = "device-uploads"

  authentication_type = "identityBased"
  identity_id         = module.upload_identity.id

  notifications_enabled = true
  sas_ttl               = "PT15M"

  depends_on = [module.upload_role]
}

module "telemetry_route" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-route.git?ref=v1.0.0"

  name                = "telemetry-to-events"
  iothub_name         = module.file_upload.iothub_name
  resource_group_name = module.resource_group.name
  routing_source      = "DeviceMessages"
  endpoint_names      = ["events"]
  enabled             = true
}

# Deliberately NOT re-emitted: the connection string. Consumers read it from the
# same secret store this configuration read it from.
output "file_upload_id" {
  value = module.file_upload.id
}

⚠️ depends_on = [module.upload_role] orders the role assignment first, but Microsoft notes the assignment takes a few minutes to propagate β€” a first apply can still fail on timing and succeed on retry. See the_identity_must_hold_storage_blob_data_contributor_before_the_first_apply.

πŸ’‘ iothub_name is emitted here precisely so the routing modules, which join the hub by name, can be wired from the same composition that joined it by ID.


πŸ“₯ Inputs

Required (3): iothub_id, connection_string (sensitive), container_name. Authentication (2): authentication_type, identity_id. Notifications (4): notifications_enabled, default_ttl, lock_duration, max_delivery_count. Device leg (1): sas_ttl. Tail (1): timeouts.

Full input schemas
Name Type Default Notes
iothub_id string β€” Resource ID. Force-new. Anchored to /providers/Microsoft.Devices/IotHubs/<name> with no trailing segment
connection_string string β€” sensitive = true. Must contain AccountName=; an IoT Hub connection string is rejected by name
container_name string β€” 3–63 chars, lowercase alphanumeric with single hyphens
authentication_type string "keyBased" Exactly keyBased or identityBased, case-sensitive
identity_id string null User-assigned identity Resource ID. Only valid with identityBased
notifications_enabled bool false Gates the three settings below
default_ttl string "PT1H" ISO 8601. Provider default differs from the portal's
lock_duration string "PT1M" ISO 8601
max_delivery_count number 10 Whole number, 1–100
sas_ttl string "PT1H" ISO 8601. Never inert
timeouts object null All four operations

🧾 Outputs

Output Description Notes
id The record's Resource ID Is the hub's own ID
iothub_id / iothub_name Parent references iothub_name derived from the ID
container_name The upload container
authentication_type / uses_identity_based_authentication Hub-to-storage leg
identity_id / uses_the_hubs_system_assigned_identity Which identity
notifications_enabled / default_ttl / lock_duration / max_delivery_count Notification queue
sas_ttl Device SAS lifetime Never inert
notification_settings_are_inert Derived
notification_settings_configured_but_inert Derived Only signal for a silent no-op
the_resource_id_is_the_hubs_own_id Constant true
only_one_file_upload_configuration_per_hub Constant true
destroy_leaves_the_configuration_live_on_the_hub Constant true Read this
a_rotated_storage_key_produces_no_plan_diff Constant true Read this
identity_based_authentication_does_not_remove_the_storage_key Constant true
plan_access_is_credential_access Constant true
the_storage_key_is_not_emitted Constant true
conflicts_with_the_inline_file_upload_on_the_iothub_module Constant true
the_existence_check_can_be_disabled_by_a_provider_feature_flag Constant true
storage_account_must_be_in_the_same_subscription_as_the_hub Constant true
the_identity_must_hold_storage_blob_data_contributor_before_the_first_apply Constant true
default_ttl_differs_from_the_portal_default Constant true
has_no_tags_or_location_of_its_own Constant true
only_the_iothub_id_is_force_new Constant true

No output is sensitive, because no output is derived from the secret. Presence is not emitted either β€” connection_string is required, so a presence flag would be a constant true dressed up as information.


🧠 Architecture Notes

The identity of this resource is the identity of its parent. The provider sets the record's Terraform ID to the hub's Resource ID with no child segment. Everything awkward about this module follows from that: there is exactly one per hub, there is nothing to for_each over, import takes the hub's ID, and two blocks pointed at one hub produce an alternating diff instead of an error.

Three legs, and this resource configures one and a half of them. Device-to-hub is the hub's own authentication. Hub-to-storage is authentication_type. Device-to-storage is always a SAS URI generated from connection_string. That third leg is why the connection string is required in every configuration and why choosing identityBased is a real improvement that is nonetheless not the improvement it appears to be.

Two operations this module cannot make visible. Rotating the storage key produces no diff, because Azure masks the key and the provider excludes it from comparison. Destroying the resource produces no change on Azure, because the delete path writes the hub back unmodified. Both are silent, both are consequential, and both get a constant output rather than a sentence in a description.

The cross-field rule is enforced at parse time, one-directionally. A validation condition may only reference its own variable, so the identity_id / authentication_type pairing is carried on identity_id and the error message names both fields. The provider enforces the same rule in its create path, at apply time; this module simply moves the failure earlier.

Sensitivity is contained by deriving nothing from the secret. connection_string is marked sensitive, and no local, no other input and no output reads it. That is why none of the 29 outputs is sensitive and why no nonsensitive() call appears anywhere in the module.


🧱 Design Principles

Concern This module's default Opt-out Why
Hub-to-storage auth keyBased (the provider's) set identityBased Flip declined, deliberately. identityBased fails unless the hub has an identity holding Storage Blob Data Contributor before the first apply β€” defaulting to it would make the empty call fail, not make it safe
Upload notifications notifications_enabled = false set true A delivery feature, not a security control
SAS URI lifetime sas_ttl = "PT1H" shorten it The provider's default; shortening is the real hardening step here
Secret emission nothing derived from the secret is emitted β€” Re-emitting copies the key into every consuming state
Secret marking sensitive = true on the input β€” Redacts plan output; does not encrypt state

πŸ”΄ The empty call is not the safe call, and this module says so rather than implying otherwise. The provider makes a credential-bearing argument required, so there is no configuration of this resource without a storage key in state. Where the secure-by-default rule cannot apply, this suite states it plainly and compensates: the value set is validated, the probable mistake is rejected by name, and the residual exposure is emitted as plan_access_is_credential_access.


πŸš€ Runbook

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

Pin ?ref=v1.0.0, never a branch. Plan-only β€” a human applies from CI.


πŸ§ͺ Testing

terraform validate proves the configuration parses and the types line up. terraform fmt -check proves formatting. Neither fires a root-module variable validation β€” terraform console with a .tfvars file does, and that is how all 14 validations here were exercised, each confirmed by the line number it reported.

What only a real plan against Azure can exercise: that the hub exists, that the storage container exists, that the identity holds Storage Blob Data Contributor, that the storage account shares the hub's subscription, and whether the first-apply guard trips.


πŸ’¬ Example Output

id                                                = "/subscriptions/.../resourceGroups/rg-iot-eastus/providers/Microsoft.Devices/IotHubs/iot-plant-eastus"
iothub_name                                       = "iot-plant-eastus"
container_name                                    = "device-uploads"
authentication_type                               = "identityBased"
uses_identity_based_authentication                = true
uses_the_hubs_system_assigned_identity            = false
notifications_enabled                             = true
sas_ttl                                           = "PT15M"
notification_settings_are_inert                   = false
notification_settings_configured_but_inert        = false
destroy_leaves_the_configuration_live_on_the_hub  = true
a_rotated_storage_key_produces_no_plan_diff       = true
plan_access_is_credential_access                  = true

πŸ” Troubleshooting

Symptom Cause Fix
identity_id can only be specified when authentication_type is identityBased Set identity_id with key-based auth This module rejects it at parse time; set authentication_type = "identityBased" or drop identity_id
Plan never converges, alternating diff Two module blocks against one hub β€” same Resource ID Use exactly one per hub
Spurious changes on every plan Both this module and the hub's inline file_upload are in use Choose one model; set file_upload = null on the hub module
Rotated the storage key, plan shows no changes Azure masks the key; the provider excludes it from the diff Expected. Force a re-apply out of band; rotation is not drift Terraform can see
terraform destroy succeeded but uploads still work The delete path writes the hub back unmodified Expected. Clear file upload on the hub itself
First apply fails on storage permissions, succeeds on retry Role assignment had not propagated Allow a few minutes after assigning Storage Blob Data Contributor
must be imported into the state The hub already has a complete file-upload configuration Import using the hub's Resource ID
A hub with a partial configuration was silently adopted The guard only trips when both connection string and container are already set Check the hub before the first apply
container_name must be 3-63 characters... Container names are lowercase-only Azure Storage naming rules, not this module's

πŸ”— Related Docs


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