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.
- π 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_accessthe single most important thing on this page.
If this module saves you time, a little support goes a long way:
- β Star the repository on GitHub.
- π Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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
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
| 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 |
| 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_readneedsregistration_read;registration_writeneedsregistration_read;enrollment_writeneeds at least one of the three. - π΄ The documentation attributes that third rule to the wrong field. The docs say
registration_write; the code saysenrollment_write. See below. - π΄ At least one permission must be granted β the provider rejects an empty policy.
β οΈ The provider'snamevalidator 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 isiothub_dps_namehere, while the sibling DPS certificate module calls itiot_dps_name.β οΈ Notags, nolocation.
| 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.
| 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.
Microsoft.Devices/provisioningServices/write also permits changing the service's SKU, its linked hubs and its allocation policy.
Microsoft.Devicesregistered 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.
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
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.
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 |
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_readis 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_writedecides which devices may provision at all. Readcontrols_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_readandregistration_writehere too, and attributes the rule toregistration_writerather thanenrollment_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 onservice_configbecause 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_onlyis emitted as its own flag rather than as the negation ofgrants_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 itsnameor 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_writeto 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_permissionsis 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-dpstakes itslinked_hubs[*].connection_stringas an input that was previously described as provisioned out of band.module.hub_link_policy.primary_connection_stringis 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.
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 |
| 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.
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin ?ref=v1.0.0, never a branch. Plan-only β a human applies from CI.
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.
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
| 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 |
- Provider resource:
azurerm_iothub_dps_shared_access_policy - Control access to Azure IoT Hub Device Provisioning Service
- Sibling modules:
terraform-azurerm-iothub-dps,terraform-azurerm-iothub-shared-access-policy,terraform-azurerm-iothub-dps-certificate - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."