Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure IoT Hub DPS Shared Access Policy Terraform Module

Creates a named shared access policy on a Device Provisioning Service and returns the SAS keys and connection strings it issues. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • πŸ”‘ Creates a named SAS policy granting any of five DPS permissions, and returns its keys.
  • βš–οΈ Enforces the provider's three permission dependencies at parse time, not at apply time.
  • 🚫 Rejects a policy that grants nothing β€” which the provider also rejects, later.
  • 🧾 Reports what the policy can actually do: granted_permissions, is_read_only, controls_who_may_provision.

πŸ’‘ Why it matters: this module deliberately emits four secrets, which is the opposite of what the rest of this suite does. A shared access policy exists to produce a credential; withholding it would make the module useless. That makes plan_access_is_credential_access the single most important thing on this page.


❀️ Support this project

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


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

This diagram is shared with terraform-azurerm-iothub-shared-access-policy and terraform-azurerm-iothub-consumer-group, authored alongside it. This module is the ...-iothub-dps-shared-access-policy node.

flowchart TB
  RG["terraform-azurerm-resource-group"]
  HUB["terraform-azurerm-iothub"]
  DPS["terraform-azurerm-iothub-dps"]
  SAS["terraform-azurerm-iothub-shared-access-policy"]
  DPSSAS["terraform-azurerm-iothub-dps-shared-access-policy"]
  CG["terraform-azurerm-iothub-consumer-group"]
  READER["a telemetry reader, outside Terraform"]
  KV["terraform-azurerm-key-vault"]

  RG -->|"name feeds resource_group_name"| HUB
  RG -->|"name feeds resource_group_name"| DPS
  HUB -->|"name feeds iothub_name"| SAS
  HUB -->|"name feeds iothub_name"| CG
  DPS -->|"name feeds iothub_dps_name"| DPSSAS
  SAS -->|"primary_connection_string, a secret"| KV
  SAS -.->|"credential for"| READER
  CG -.->|"read cursor for"| READER
  SAS -.->|"linked_hubs connection string"| DPS

  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 SAS,DPSSAS,CG batch
  class HUB,DPS anchor
  class RG,READER,KV ext
Loading

🧬 What this module builds

flowchart TB
  NAME["name<br/>force new, anchored here because the provider does not anchor it"]
  PARENT["iothub_dps_name<br/>resource_group_name<br/>both force new"]
  PERMS["registration_read, registration_write<br/>enrollment_read, enrollment_write<br/>service_config"]
  T["azurerm_iothub_dps_shared_access_policy.this"]
  DERIVED["granted_permissions, permission_count<br/>grants_write_access, is_read_only<br/>controls_who_may_provision"]
  SECRETS["primary_key, secondary_key<br/>primary_connection_string, secondary_connection_string<br/>all four SENSITIVE and emitted"]
  RULES["three dependencies enforced at parse time<br/>the_documented_enrollment_write_rule_names_the_wrong_field"]
  RISK["plan_access_is_credential_access<br/>rotating_the_keys_is_not_possible_from_this_module<br/>permissions_update_in_place_and_take_effect_immediately"]

  NAME --> T
  PARENT --> T
  PERMS --> T
  PERMS --> DERIVED
  PERMS --> RULES
  T --> SECRETS
  SECRETS --> RISK

  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 NAME,PARENT,PERMS,DERIVED,SECRETS io
  class RULES,RISK note
Loading
Resource Cardinality Notes
azurerm_iothub_dps_shared_access_policy.this one per policy name, per DPS Keyed by name, force-new, and embedded in every connection string

βœ… 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:

  • πŸ”΄ Four computed attributes are secrets: primary_key, secondary_key, and both connection strings. They live in state in plaintext.
  • πŸ”΄ Three permission dependencies, all enforced by the provider: enrollment_read needs registration_read; registration_write needs registration_read; enrollment_write needs at least one of the three.
  • πŸ”΄ The documentation attributes that third rule to the wrong field. The docs say registration_write; the code says enrollment_write. See below.
  • πŸ”΄ At least one permission must be granted β€” the provider rejects an empty policy.
  • ⚠️ The provider's name validator is unanchored and enforces none of the three rules its own comment states.
  • ⚠️ The permission booleans are not force-new, so widening a policy takes effect immediately for every existing holder of its keys.
  • ⚠️ The parent argument is iothub_dps_name here, while the sibling DPS certificate module calls it iot_dps_name.
  • ⚠️ No tags, no location.

πŸ”΄ Three published statements of one rule, all different

Source What it says
Provider documentation registration_write requires enrollment_read, registration_read and registration_write
Provider error message enrollment_write requires all three of those
Provider code enrollment_write requires at least one of those three

This module enforces the code, because that is what actually rejects a configuration and because either stricter reading would refuse input the provider accepts. the_documented_enrollment_write_rule_names_the_wrong_field records the discrepancy.


πŸ”‘ Required Azure RBAC Roles / Permissions

Principal Permission Scope Why
The Terraform identity Contributor, or a custom role with Microsoft.Devices/provisioningServices/write The provisioning service Policies are stored on the service, so adding one is a write to it
The Terraform identity Microsoft.Devices/provisioningServices/read The provisioning service Refresh, plan, and the existence check
The Terraform identity .../provisioningServices/listkeys/action The provisioning service Reading the keys back, which every refresh does

πŸ”΄ Plan access is credential access. The keys and connection strings are computed attributes Terraform reads from Azure on every refresh and stores in state in plaintext. Anyone who can plan, or read state, holds a working credential for the provisioning service. sensitive = true redacts plan output; it does not encrypt state. Grant plan rights only to principals you would hand that credential 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/provisioningServices/write also permits changing the service's SKU, its linked hubs and its allocation policy.

⚠️ The credential's blast radius is bounded by its permissions, not by Azure RBAC. A leaked connection string is usable by anyone, from anywhere the service is reachable, until the policy is deleted or replaced.


Azure Prerequisites

  • Microsoft.Devices registered on the subscription.
  • An existing Device Provisioning Service, referenced by name.
  • πŸ”΄ An encrypted, access-controlled state backend. Not optional for this module.
  • πŸ”΄ A decision about which permissions are genuinely needed, since the provider rejects an empty policy and this module rejects it earlier.
  • No resource group or region to choose for this record.

πŸ“ Module Structure

terraform-azurerm-iothub-dps-shared-access-policy/
β”œβ”€β”€ providers.tf    # required_version + pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 9 typed inputs, 11 validations
β”œβ”€β”€ main.tf         # derived locals + the single azurerm_iothub_dps_shared_access_policy.this
β”œβ”€β”€ outputs.tf      # 27 outputs, 4 of them sensitive
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

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

  name                = "provisioning-reader"
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read = true
}

πŸ”’ The caller configures provider "azurerm" { features {} }, authentication, and the state backend. This module puts a live credential in that state.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
iothub_dps_name string (name) terraform-azurerm-iothub-dps β†’ name
resource_group_name string terraform-azurerm-resource-group β†’ name

Emits

Output Description Consumed by
id Resource ID β€” DPS ID plus /keys/<name> Audit
primary_connection_string πŸ”’ The credential most consumers want A back-end service, out of band
granted_permissions What the policy can do check blocks
plan_access_is_credential_access Constant true Security review

πŸ“š Example Library

1 Β· Minimal read-only policy
module "provisioning_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps-shared-access-policy.git?ref=v1.0.0"

  name                = "provisioning-reader"
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read = true
}

ℹ️ registration_read is the permission most others depend on, which makes it the natural floor for a read-only policy.

2 Β· A policy that manages enrolments
module "enrolment_manager" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps-shared-access-policy.git?ref=v1.0.0"

  name                = "enrolment-manager"
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read = true
  enrollment_read   = true
  enrollment_write  = true
}

πŸ”΄ enrollment_write decides which devices may provision at all. Read controls_who_may_provision.

3 Β· The dependency the provider enforces
# REJECTED at parse time:
#   registration_write = true      (without registration_read)
#   enrollment_read    = true      (without registration_read)

⚠️ Both dependencies are documented and enforced by the provider. This module moves the failure from apply time to parse time; the error names both fields and says which side carries the check, because a validation condition may only reference its own variable.

4 Β· The rule whose three published forms disagree
# ACCEPTED here, and by the provider's code:
module "enrolment_writer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps-shared-access-policy.git?ref=v1.0.0"

  name                = "enrolment-writer"
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read = true
  enrollment_write  = true
}

output "documented_rule_is_wrong" {
  value = module.enrolment_writer.the_documented_enrollment_write_rule_names_the_wrong_field
}

πŸ”΄ The provider's documentation would require enrollment_read and registration_write here too, and attributes the rule to registration_write rather than enrollment_write. Its error message demands all three. Its code requires only one of them. This module follows the code, so the configuration above is accepted β€” as the provider will accept it.

5 Β· Refusing a policy that grants nothing
# REJECTED at parse time: every permission left at its default of false.
#   name                = "does-nothing"
#   iothub_dps_name     = module.dps.name
#   resource_group_name = module.resource_group.name

⚠️ The provider rejects this too, at apply time. The check is placed on service_config because it has to live on some variable, and the error message says so rather than appearing to blame a field the caller never touched.

6 Β· Asserting least privilege
check "provisioning_reader_stays_read_only" {
  assert {
    condition     = module.provisioning_reader.is_read_only
    error_message = "This policy was meant to observe, not to change anything."
  }
}

check "nobody_unexpected_admits_devices" {
  assert {
    condition     = !module.provisioning_reader.controls_who_may_provision
    error_message = "This policy can create enrolments and therefore admit new devices."
  }
}

πŸ’‘ is_read_only is emitted as its own flag rather than as the negation of grants_write_access, because confirming a credential is read-only is a positive claim a reviewer wants to make directly.

7 Β· Passing the credential onward without re-emitting it
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

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

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

  name                = "service-api"
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read  = true
  registration_write = true
}

# Correct: consume it inside this configuration.
resource "azurerm_key_vault_secret" "dps_service" {
  name         = "dps-service-connection-string"
  value        = module.service_policy.primary_connection_string
  key_vault_id = var.key_vault_id
}

# WRONG, and deliberately not done here:
#   output "dps_connection_string" { value = module.service_policy.primary_connection_string }
# That copies the credential into the CALLING configuration's state as well, for no benefit.

πŸ”’ The module emits the secret because that is its purpose. The composition's job is to stop the copy there.

8 Β· Why the keys cannot be rotated from here
output "rotation_needs_a_replacement" {
  value = module.service_policy.rotating_the_keys_is_not_possible_from_this_module
}

⚠️ The keys are computed by Azure and the resource exposes no rotation argument. Changing permissions leaves both keys untouched. The only way to get new keys through Terraform is to replace the policy β€” by changing its name or a parent reference β€” which invalidates the old credential immediately for every consumer still holding it. Plan that as a two-policy cutover, not a rename.

9 Β· Widening a policy is silent and immediate
output "changes_take_effect_at_once" {
  value = module.service_policy.permissions_update_in_place_and_take_effect_immediately
}

πŸ”΄ The permission booleans are not force-new, so adding enrollment_write to an existing policy is an in-place update. The keys do not change, nothing reconnects, and every holder of that connection string silently gains the new authority. Narrowing works the same way and is the safe direction.

10 Β· Several policies with `for_each`
locals {
  policies = {
    "provisioning-reader" = { registration_read = true }
    "enrolment-manager"   = { registration_read = true, enrollment_read = true, enrollment_write = true }
  }
}

module "dps_policies" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps-shared-access-policy.git?ref=v1.0.0"
  for_each = local.policies

  name                = each.key
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read  = try(each.value.registration_read, false)
  registration_write = try(each.value.registration_write, false)
  enrollment_read    = try(each.value.enrollment_read, false)
  enrollment_write   = try(each.value.enrollment_write, false)
  service_config     = try(each.value.service_config, false)
}

output "policy_permissions" {
  value = { for k, m in module.dps_policies : k => m.granted_permissions }
}

πŸ’‘ granted_permissions is safe to emit upward β€” it is derived from the input booleans, not from any key, so it carries no sensitivity. That is the useful half of the module to expose.

11 Β· A SAS policy is not an RBAC grant
output "not_governed_by_entra" {
  value = module.service_policy.is_a_data_plane_credential_not_an_rbac_grant
}

πŸ”΄ This is a symmetric-key credential for the service's data plane. It grants nothing in Azure Resource Manager, appears in no access review, is not covered by Conditional Access or Privileged Identity Management, and cannot be revoked by removing a role. Prefer Entra ID authentication where the client supports it.

12 Β· The unanchored name check
# ACCEPTED by the provider, REJECTED here:
#   name = "provisioning reader"     (a space)
#   name = "aaaa...200 characters"   (over the stated 64-character cap)

⚠️ The provider's validator carries a comment stating three rules and then tests an unanchored regex, so any value containing one legal character passes at plan time and fails against Azure. This module anchors the same expression. The sibling hub policy module shares the validator and the same gap.

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

A provisioning service, the hub it provisions into, a least-privilege policy on each, and the hub credential wired into the DPS link β€” which is the one place this pair of modules genuinely feeds each other.

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 "iothub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub.git?ref=v1.0.0"

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

# The hub-side credential DPS needs in order to register devices into the hub.
module "hub_link_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-shared-access-policy.git?ref=v1.0.0"

  name                = "dps-link"
  iothub_name         = module.iothub.name
  resource_group_name = module.resource_group.name

  registry_read   = true
  registry_write  = true
  service_connect = true
}

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

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

# The DPS-side credential a back-end service uses to read registrations.
module "dps_reader" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps-shared-access-policy.git?ref=v1.0.0"

  name                = "provisioning-reader"
  iothub_dps_name     = module.dps.name
  resource_group_name = module.resource_group.name

  registration_read = true
}

check "dps_reader_is_read_only" {
  assert {
    condition     = module.dps_reader.is_read_only
    error_message = "The provisioning reader must not be able to change anything."
  }
}

check "hub_link_is_not_full_access" {
  assert {
    condition     = !module.hub_link_policy.is_effectively_iothubowner
    error_message = "The DPS link policy grants everything; drop a permission or use the built-in policy honestly."
  }
}

# Deliberately NOT re-emitted: any key or connection string. Only the non-secret
# facts leave this configuration.
output "policy_summary" {
  value = {
    hub_link   = module.hub_link_policy.granted_permissions
    dps_reader = module.dps_reader.granted_permissions
  }
}

⚠️ terraform-azurerm-iothub-dps takes its linked_hubs[*].connection_string as an input that was previously described as provisioned out of band. module.hub_link_policy.primary_connection_string is now a real source for it inside one configuration β€” at the cost of putting that credential in this state file, which is the trade this module exists to make visible.


πŸ“₯ Inputs

Required (3): name, iothub_dps_name, resource_group_name. Permissions (5, all bool, default false): registration_read, registration_write, enrollment_read, enrollment_write, service_config. Tail (1): timeouts.

Full input schemas
Name Type Default Notes
name string β€” Force-new. 1–64 chars of a-zA-Z0-9!._-, anchored here
iothub_dps_name string β€” Force-new. Validated with the IoT Hub name rule
resource_group_name string β€” Force-new
registration_read bool false The dependency of the two rules below
registration_write bool false Requires registration_read
enrollment_read bool false Requires registration_read
enrollment_write bool false Requires at least one of the other three
service_config bool false Also carries the at-least-one-permission check
timeouts object null All four operations

🧾 Outputs

Output Description Notes
id / name / iothub_dps_name / resource_group_name Identity and force-new references
registration_read / registration_write / enrollment_read / enrollment_write / service_config As configured
granted_permissions / permission_count Derived from the inputs Safe to emit upward
grants_write_access / is_read_only Derived check blocks
controls_who_may_provision Derived Security review
primary_key / secondary_key πŸ”’ sensitive The deliverable
primary_connection_string / secondary_connection_string πŸ”’ sensitive The deliverable
plan_access_is_credential_access Constant true Read this first
the_keys_are_the_deliverable_so_they_are_emitted Constant true Design review
rotating_the_keys_is_not_possible_from_this_module Constant true Operations
permissions_update_in_place_and_take_effect_immediately Constant true Change review
the_provider_name_check_is_unanchored_so_this_module_anchors_it Constant true
the_documented_enrollment_write_rule_names_the_wrong_field Constant true Read this
is_a_data_plane_credential_not_an_rbac_grant Constant true Security review
has_no_tags_or_location_of_its_own Constant true
only_the_name_and_parent_references_are_force_new Constant true

Exactly four outputs are sensitive, and they are exactly the four secret attributes. No local reads any of them, so nothing else in the module carries their sensitivity.


🧠 Architecture Notes

This module breaks the suite's usual rule on purpose. A secret-bearing computed attribute is normally withheld. Here the credential is the entire product of the resource, so withholding it would push callers to dig the key out of the state file by hand β€” strictly worse. The four secrets are emitted, marked sensitive, and the composition example shows the discipline that actually matters: consume them where they are needed and do not re-emit them upward.

The containment that is still possible was still done. No local and no non-secret output reads a key or a connection string, so the sensitivity does not spread through the module, and the useful derived facts β€” granted_permissions, is_read_only, controls_who_may_provision β€” are computed from the input booleans and are safe to expose.

Three cross-field rules, all placed rather than gathered. A validation condition may only reference its own variable, so each dependency lives on the variable that triggers it and the at-least-one rule lives on service_config. Each error message names the other fields and says why the check is on that side, so a message about service_config does not read as blaming a field nobody touched.

Where three published statements of a rule disagree, the code wins. The docs name the wrong trigger field and the error message overstates the requirement. Enforcing either would reject configurations the provider accepts, so this module matches the code and records the discrepancy in an output.

The dangerous change here is a widening, not a destruction. Permissions update in place, keys stay the same, and nothing reconnects β€” so a policy can quietly gain authority for every consumer already holding it. That is why the permission set gets three derived flags rather than being left as five booleans.


🧱 Design Principles

Concern This module's default Opt-out Why
Every permission false set the ones needed The empty call cannot be applied at all, and this module says so at parse time
Empty policy Rejected none The provider rejects it too, later
Permission dependencies Enforced at parse time none Documented and enforced by the provider
The disputed enrollment_write rule Enforced as the code has it none Either stricter reading would reject legal input
The four secrets Emitted, marked sensitive β€” The credential is the deliverable; the composition declines to re-emit
name grammar Anchored none The provider states the rule and does not enforce it

πŸ”΄ The empty call is not the safe call β€” it is an invalid one. The provider requires at least one permission, so there is no configuration of this resource that grants nothing. Where the secure-by-default rule cannot apply, this suite states it plainly and compensates: every dependency is enforced early, and the resulting authority is emitted as three derived flags.


πŸš€ 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 11 validations here were exercised, each confirmed by the line number it reported, including every permission dependency in both the failing and the passing direction. The secret handling was proven structurally: no local references the resource, and exactly the four secret attributes are emitted, all four marked sensitive.

What only a real plan against Azure can exercise: that the provisioning service exists, that the name is unused, and the actual key values.


πŸ’¬ Example Output

id                          = "/subscriptions/.../provisioningServices/dps-fleet-eastus/keys/provisioning-reader"
name                        = "provisioning-reader"
granted_permissions         = ["registration_read"]
permission_count            = 1
grants_write_access         = false
is_read_only                = true
controls_who_may_provision  = false
primary_key                 = (sensitive value)
primary_connection_string   = (sensitive value)
plan_access_is_credential_access = true

πŸ” Troubleshooting

Symptom Cause Fix
At least one permission must be granted Every boolean left false Grant one; the provider rejects an empty policy too
registration_write requires registration_read A documented provider dependency Set registration_read = true
enrollment_read requires registration_read Same Set registration_read = true
enrollment_write requires at least one of... The rule as the provider's code has it Add one of the three
The docs demand more than this module does The docs, the error message and the code disagree This module follows the code β€” see the table above
name must be 1-64 characters... The provider's own check is unanchored and would have passed it Azure would reject it later
A consumer lost access after a rename Renaming replaces the policy and issues new keys Cut over with a second policy instead
Rotated nothing after changing permissions Permission changes never touch the keys Replace the policy to get new ones
Keys visible in state Computed secret attributes always are Encrypt the backend; restrict plan rights

πŸ”— Related Docs


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