Skip to content

Move ADR certificate management to the certificate-authority model - #447

Open
Ewerton Scaboro da Silva (ewertons) wants to merge 29 commits into
masterfrom
port-adr-certificate-authority-model
Open

Move ADR certificate management to the certificate-authority model#447
Ewerton Scaboro da Silva (ewertons) wants to merge 29 commits into
masterfrom
port-adr-certificate-authority-model

Conversation

@ewertons

Copy link
Copy Markdown
Contributor

Certificate management provisioning no longer works: the ADR object model it targeted was public preview and has been replaced.

A certificate policy used to hang off a namespace credential and be referenced from an enrollment by a single name. It now hangs off an issuing CA, and an enrollment references it by three names that must travel together. The hub and DPS used to be pointed at a namespace and a shared user-assigned identity when they were created; the relationship is now expressed on the namespace, as endpoints linked to it afterwards, with each resource authenticating as its own system-assigned identity.

Changes

  • Create the root -> issuing CA -> certificate policy chain, after linking, so ADR has a hub to sync the issuing CA certificate to. A policy created under a root is rejected with PolicyRequiresIssuingCa.
  • Link the hub and DPS to the namespace and wait for the endpoints, retrying while the namespace identity grants replicate, and healing a namespace left Failed by an otherwise successful link.
  • Write enrollments over the DPS service API, carrying the three policy names. The CLI cannot express them: it only ever offered --credential-policy, and the az iot adr group ships only in preview builds and still models the retired shape.
  • Push the linked namespace into the DPS data plane with a tags-only update. Committing a link does not push scale-unit configuration, so without it every enrollment write fails with errorCode 400004.
  • Skip linked-hub creation under certificate management, where ADR chooses the provisioning targets and the DPS list is read-only (IH409313).
  • Read linked hubs from the namespace endpoints. They were read from the DPS resource captured before the hub is attached, so the list was always empty.
  • Drop the extension version pin, the credential sync, the Contributor grant to the IoT Hub first-party application, and the Onboarding role, all of which belonged to the previous model.

TestEnvironmentInfo.AzureAdrPolicyName is replaced by AdrPolicy, and the generated test configuration exports the namespace and certificate authority names alongside ADR_CERT_MGMT_POLICY_NAME. No other file in the repository referenced the old property.

Verification

Run locally against PowerShell 7.4.6; the module parses and imports cleanly, and 37 assertions pass across two suites:

  • Unit: policy reference all-or-nothing semantics, enrollment body shape, TestEnvironmentInfo JSON round-trip, and the DPS SAS token checked against an independent implementation of the same HMAC-SHA256 construction.
  • Flow: the provisioning sequence driven against a stubbed CLI, asserting the three certificate authority resources are created in order with the policy under the issuing CA, the enrollment PUT goes to the service API at the ADR-aware api-version with all three policy names and no credentialPolicyName, and no retired command or flag is used.

Live provisioning against a subscription has not been run and is the remaining validation: create an environment with -EnableCertificateManagement, confirm the chain and endpoints reach Succeeded, and confirm the first enrollment write is not rejected.

Certificate management provisioning no longer works: the ADR object model it
targeted was public preview and has been replaced.

A certificate policy used to hang off a namespace credential and be referenced
from an enrollment by a single name. It now hangs off an issuing CA, and an
enrollment references it by three names that must travel together. The hub and
DPS used to be pointed at a namespace and a shared user-assigned identity when
they were created; the relationship is now expressed on the namespace, as
endpoints linked to it afterwards, with each resource authenticating as its own
system-assigned identity.

- Create the root -> issuing CA -> certificate policy chain, after linking, so
  ADR has a hub to sync the issuing CA certificate to. A policy created under a
  root is rejected with PolicyRequiresIssuingCa.
- Link the hub and DPS to the namespace and wait for the endpoints, retrying
  while the namespace identity's grants replicate and healing a namespace left
  Failed by an otherwise successful link.
- Write enrollments over the DPS service API, carrying the three policy names.
  The CLI cannot express them: it only ever offered --credential-policy.
- Push the linked namespace into the DPS data plane with a tags-only update.
  Committing a link does not push scale-unit configuration, so without it every
  enrollment write fails with errorCode 400004.
- Skip linked-hub creation under certificate management, where ADR chooses the
  provisioning targets and the DPS's own list is read-only.
- Read linked hubs from the namespace endpoints. They were read from the DPS,
  which is captured before the hub is attached, so the list was always empty.
- Drop the extension version pin, the credential sync, the Contributor grant to
  the IoT Hub first-party application and the Onboarding role, all of which
  belonged to the previous model.

TestEnvironmentInfo.AzureAdrPolicyName is replaced by AdrPolicy, and the
generated test configuration exports the namespace and certificate authority
names alongside ADR_CERT_MGMT_POLICY_NAME.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The ADR namespace linking payload appears to use the IoT Hub endpoint type for the DPS endpoint, which is likely to break provisioning links at runtime.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR updates the PowerShell provisioning/test module to use the post-public-preview Azure Device Registry (ADR) certificate-authority model for certificate management, replacing the previous CLI-driven ADR surface area with direct ARM + DPS service-API calls.

Changes:

  • Removes the azure-iot CLI extension version pin and routes ADR operations through az rest (ARM) instead of az iot adr.
  • Adds ADR provisioning/linking helpers (namespace creation, endpoint linking, CA chain + policy creation) and waits/polling utilities.
  • Writes DPS enrollments via the DPS service REST API to carry the three-part ADR policy reference (namespace/CA/policy).
File summaries
File Description
scripts/Azure.Iot.Sdk.Test.psm1 Reworks ADR certificate management provisioning and DPS enrollment creation to match the new certificate-authority model.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/Azure.Iot.Sdk.Test.psm1 Outdated
The DPS provisioning endpoint was sent with the IoT Hub resource type, which
mis-models the link. Three further problems in the same function, found while
confirming that one:

- The hub messaging endpoint was missing its provisioning availability, so it
  was linked but not offered as a provisioning target.
- The link was submitted as a properties-only update. The saga starts on a
  namespace write carrying location and identity.
- Reconciling a namespace left Failed re-sent the endpoints, which is rejected
  as immutable, and judged the result on the first read, which still reports
  Failed for a while. It is now a tags-only update, polled to Succeeded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The updated E2E config generators dereference TestEnvInfo.AdrPolicy.* unconditionally, which will throw when certificate management isn’t enabled (AdrPolicy is null), breaking config generation for non-ADR environments.

Review details

Suppressed comments (7)

Previously missed (2) — in code that hasn't changed since the last review.

scripts/Azure.Iot.Sdk.Test.psm1:1577

  • The ADR/DPS api-version constants are future-dated relative to the current date (e.g. 2026-11-02-preview). If these versions aren't available in the target cloud/subscription yet, every az rest call will fail with an invalid/unsupported api-version error. Consider allowing these to be overridden via environment variables (or module parameters) so runs can be unblocked without code changes.
    scripts/Azure.Iot.Sdk.Test.psm1:3150
  • TestEnvInfo.AdrPolicy can be $null when certificate management wasn't enabled (e.g., environments created without -EnableCertificateManagement or JSON loaded from older configs). These lines dereference .NamespaceName/etc unconditionally, which will throw and prevent config generation even for non-ADR runs. Consider emitting empty strings when AdrPolicy is null (matching the previous string-based behavior).

This issue also appears in the following locations of the same file:

  • line 3179
  • line 3256
  • line 3278
  • line 3340
  • line 3370

scripts/Azure.Iot.Sdk.Test.psm1:3181

  • Same null-dereference issue as the PowerShell block above: this bash config generation path also assumes TestEnvInfo.AdrPolicy is non-null and will throw when it isn't. Emit empty strings when AdrPolicy is null to keep config generation working for non-ADR environments.
            "export ADR_CERT_MGMT_NAMESPACE_NAME=`"$($TestEnvInfo.AdrPolicy.NamespaceName)`""
            "export ADR_CERT_MGMT_CERTIFICATE_AUTHORITY_NAME=`"$($TestEnvInfo.AdrPolicy.CertificateAuthorityName)`""
            "export ADR_CERT_MGMT_POLICY_NAME=`"$($TestEnvInfo.AdrPolicy.CertificatePolicyName)`""

scripts/Azure.Iot.Sdk.Test.psm1:3258

  • New-AzIotNetSDKE2ETestConfig dereferences TestEnvInfo.AdrPolicy.* unconditionally. If the environment was created without certificate management, AdrPolicy is $null and config generation will throw. Use empty strings (or conditionally omit these variables) when AdrPolicy is not set.
            "`$env:ADR_CERT_MGMT_NAMESPACE_NAME = `"$($TestEnvInfo.AdrPolicy.NamespaceName)`""
            "`$env:ADR_CERT_MGMT_CERTIFICATE_AUTHORITY_NAME = `"$($TestEnvInfo.AdrPolicy.CertificateAuthorityName)`""
            "`$env:ADR_CERT_MGMT_POLICY_NAME = `"$($TestEnvInfo.AdrPolicy.CertificatePolicyName)`""

scripts/Azure.Iot.Sdk.Test.psm1:3280

  • Same null-dereference issue as the PowerShell block above: the bash path assumes TestEnvInfo.AdrPolicy is non-null and will throw otherwise. Emit empty strings when AdrPolicy is $null to preserve backward compatibility.
            "export ADR_CERT_MGMT_NAMESPACE_NAME=`"$($TestEnvInfo.AdrPolicy.NamespaceName)`""
            "export ADR_CERT_MGMT_CERTIFICATE_AUTHORITY_NAME=`"$($TestEnvInfo.AdrPolicy.CertificateAuthorityName)`""
            "export ADR_CERT_MGMT_POLICY_NAME=`"$($TestEnvInfo.AdrPolicy.CertificatePolicyName)`""

scripts/Azure.Iot.Sdk.Test.psm1:3342

  • New-AzIotPythonSDKE2ETestConfig dereferences TestEnvInfo.AdrPolicy.* unconditionally. If AdrPolicy is $null (non-certificate-management environment), this will throw and block config generation. Consider emitting empty strings when AdrPolicy is not present.
            "`$env:ADR_CERT_MGMT_NAMESPACE_NAME = `"$($TestEnvInfo.AdrPolicy.NamespaceName)`""
            "`$env:ADR_CERT_MGMT_CERTIFICATE_AUTHORITY_NAME = `"$($TestEnvInfo.AdrPolicy.CertificateAuthorityName)`""
            "`$env:ADR_CERT_MGMT_POLICY_NAME = `"$($TestEnvInfo.AdrPolicy.CertificatePolicyName)`""

scripts/Azure.Iot.Sdk.Test.psm1:3372

  • Same null-dereference issue as the PowerShell block above: this bash block also assumes TestEnvInfo.AdrPolicy is non-null and will throw otherwise. Emit empty strings when AdrPolicy is $null.
            "export ADR_CERT_MGMT_NAMESPACE_NAME=`"$($TestEnvInfo.AdrPolicy.NamespaceName)`""
            "export ADR_CERT_MGMT_CERTIFICATE_AUTHORITY_NAME=`"$($TestEnvInfo.AdrPolicy.CertificateAuthorityName)`""
            "export ADR_CERT_MGMT_POLICY_NAME=`"$($TestEnvInfo.AdrPolicy.CertificatePolicyName)`""
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The generated test configuration reads the three policy names directly. With no
certificate management the reference was null, which renders as empty strings
normally but throws under Set-StrictMode, so config generation could break for
environments that never asked for ADR.

An environment now always carries a reference; it is simply incomplete when
certificate management is off. Callers already gate on IsComplete(), so nothing
else changes, and the emitted values match the previous empty strings.

The ADR and DPS api-versions can also be overridden from the environment, so a
cloud or region where one is not registered can be unblocked without a code
change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Addressed both points from the second review in 4fdd94c.

Null AdrPolicy in the config generators. Confirmed: it renders as empty strings normally but throws under Set-StrictMode -Version 3.0, so the risk is real. Rather than guard twelve call sites, an environment now always carries a reference and it is simply incomplete when certificate management is off. Callers already gate on IsComplete(), so the emitted values match the previous empty-string behaviour. Covered by a test that renders the three names under StrictMode.

Future-dated api-versions. These are the versions the certificate-authority model exists at, so they cannot be lowered, but they are now overridable via ADR_API_VERSION, DPS_CONTROL_PLANE_API_VERSION and DPS_ENROLLMENT_API_VERSION so a cloud or region where one is not registered can be unblocked without a code change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It makes large, high-impact changes to live Azure provisioning behavior (preview ARM api-versions, role/linking sagas, and DPS service-API enrollment writes) that require human validation in a real subscription.

Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread scripts/Azure.Iot.Sdk.Test.psm1
Removing the version pin means the stable azure-iot extension is installed, and
'az iot dps create' accepts identity flags only in preview builds of it. The DPS
was being created with --mi-system-assigned, which the stable extension rejects
as an unrecognized argument, so provisioning would have failed on exactly the
version the unpinned install now selects.

The DPS is created plain again and its system-assigned identity is turned on
through ARM, alongside the rest of the ADR wiring, and only when certificate
management is enabled. The IoT Hub keeps --mi-system-assigned, which is a core
CLI flag rather than an extension one.

An extension that is already installed is also updated rather than left as it
is, so a long-lived agent cannot keep provisioning with a stale one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

There is a confirmed null-dereference bug in the new ARM provisioning-state wait loop, and the default ADR/DPS API versions are dated in the future and may break provisioning without overrides.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

scripts/Azure.Iot.Sdk.Test.psm1:501

  • Wait-AzProvisioningState calls (Invoke-AzRest -AllowFailure).properties.provisioningState directly. When Invoke-AzRest returns $null (any transient ARM/CLI failure), this will throw a null-reference and abort the wait loop, defeating the purpose of -AllowFailure.
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Wait-AzProvisioningState read the provisioning state straight off the result of
a call it had explicitly allowed to fail. These resources are polled for
minutes, and a transient ARM or CLI failure along the way says nothing about the
provisioning itself, so the failed read is now treated as an unknown state for
that attempt and the wait continues against its deadline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in 80b9b3f.

Null dereference in Wait-AzProvisioningState. Valid. Confirmed the behaviour: $null.properties.provisioningState yields $null normally but throws under Set-StrictMode -Version 3.0, and either way reading the state straight off a call that was explicitly allowed to fail contradicted the intent. A failed read is now an unknown state for that attempt, and the wait continues against its deadline. Covered by a test that fails the first read and requires the wait to still settle.

Future-dated api-versions. These cannot be lowered: the certificate-authority resource tree exists only at 2026-11-02-preview and later, which is the whole reason for this change; the earlier versions expose the retired model. They are overridable via ADR_API_VERSION, DPS_CONTROL_PLANE_API_VERSION and DPS_ENROLLMENT_API_VERSION for a cloud or region where one is not registered.

Local verification: module parses and imports, 43 assertions pass across the unit and flow suites. Live provisioning against a subscription remains the outstanding validation, as noted in the description.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The defaults include future-dated api-version strings and there is at least one reliability issue around masking az extension update failures that should be addressed before merging.

Review details

Suppressed comments (1)

scripts/Azure.Iot.Sdk.Test.psm1:167

  • Install-AzureIotCliExtension unconditionally suppresses az extension update stderr and then forces $LASTEXITCODE = 0. This hides real update failures (e.g., transient network issues, extension install corruption), which can later surface as confusing provisioning errors while the log claims the extension was updated. It’s safer to ignore only the known "already up to date" failure mode and fail fast for other errors.
        # Already up to date is reported as a failure, so the exit code is reset rather than checked.
        Write-Host "Azure IoT extension $($Extension.version) found; updating."
        az extension update --name azure-iot --only-show-errors 2>$null | Out-Null
        $global:LASTEXITCODE = 0
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The update suppressed stderr and reset the exit code, on the belief that being
already up to date is reported as a failure. It is not: with no newer version
available the command warns and returns successfully. The suppression therefore
bought nothing and hid genuine failures, which would have surfaced later as
confusing provisioning errors against a log claiming the extension was updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Addressed in 8fac417.

Masked az extension update failures. Correct, and my justifying comment was simply wrong. Checked the CLI source: when no newer version is available update_extension hits NoExtensionCandidatesError, logs a warning and returns, so the command exits successfully. Being already up to date was never reported as a failure, so the suppression bought nothing and only hid real errors. Both the redirect and the exit-code reset are gone; it now fails fast via Stop-OnError.

Future-dated api-versions. As above, these cannot be lowered without reverting to the retired model, and all three are overridable from the environment.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

There are confirmed null-handling bugs in tags-only PATCH flows (which can break reconciliation/dataplane sync) and a polling robustness issue that can fail provisioning on transient ARM reads.

Review details

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

scripts/Azure.Iot.Sdk.Test.psm1:1704

  • Connect-AdrNamespace polls for endpoint linkingState for up to 15 minutes, but a single transient failure to read the namespace via Invoke-AzRest will currently stop provisioning immediately. This is inconsistent with Wait-AzProvisioningState, which treats transient ARM/CLI read failures as non-fatal during polling.
    scripts/Azure.Iot.Sdk.Test.psm1:1732
  • In the namespace reconciliation path, $Namespace.tags can be $null when the resource has no tags; calling .PSObject.Properties on $null will throw and prevent recovery from provisioningState=Failed. Guard the tag enumeration so the reconcile PATCH always succeeds.

This issue also appears on line 1834 of the same file.

scripts/Azure.Iot.Sdk.Test.psm1:1837

  • Sync-DpsAdrConfiguration builds $Tags by enumerating (Invoke-AzRest ...).tags.PSObject.Properties, but .tags can be $null when no tags are set, causing the best-effort sync to always fail early (and skip the PATCH that triggers the dataplane push). Guard the tag enumeration so the PATCH is attempted even with no existing tags.
    try {
        $Tags = @{}
        (Invoke-AzRest -Url $Url).tags.PSObject.Properties | %{ $Tags[$_.Name] = $_.Value }
        $Tags["AdrDataplaneSyncUtc"] = (Get-Date).ToUniversalTime().ToString("o")
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Both tags-only updates copied the resource's current tags by piping the tags
property into ForEach-Object. Piping a null still runs the body once, with a
null key, so on a resource carrying no tags the copy threw. Neither the ADR
namespace nor the DPS is created with tags, so this was the normal case, not an
edge one: the namespace reconcile that recovers a link left Failed would throw
instead of recovering, and the DPS data-plane push would fail before it was
attempted, leaving every enrollment write to fail with errorCode 400004.

The namespace link poll also gave up the whole run on a single failed read, over
a window of up to fifteen minutes. It now treats a failed read the same way the
provisioning-state wait does: an attempt spent, not a failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI added 16 commits September 4, 2026 20:04
The DPS authenticates to the ADR namespace as itself, so it needs a
system-assigned identity. Turning that on after creation does not work: DPS
rejects a managed-identity PATCH with IH400158, "Patch for managed identity
information is not supported". The identity flags on 'az iot dps create' are
not an option either, since they exist only in preview builds of the azure-iot
extension.

The DPS is therefore created through ARM with the identity in the request, the
way the IoT Hub already is, and the separate enable step is gone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Linking failed with LinkableResourceNotReady on every attempt: the saga acts as
the namespace identity against the hub and DPS, and as those resources'
identities against the namespace, so Contributor has to be granted BOTH ways.
Only one direction was, and the namespace identity also lacked Contributor on
the DPS and on the namespace itself, which is what ADR's device-create runs as.

Also added the two grants the flow needs once linked: the DPS identity needs IoT
Hub Data Contributor to register devices, and issuing a certificate for a CSR
needs an ADR data action that no built-in role carries, so a custom role is
created for it and granted to the DPS identity.

Linking now gets a head start after the grants are created and retries for
longer, with the wait growing to a cap. Being readable is not the same as being
enforced, and the previous budget of five attempts thirty seconds apart expired
while replication was still in progress.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The subscription is at its custom role definition limit, so creating one failed
the run outright. Matching only on the expected name meant a role granting the
same action under a different name was ignored. Roles assignable at the
subscription are now matched by name first and by the data action second, and a
new definition is only created when neither exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Creating a custom role for the ADR certificate-issuance data action failed with
RoleDefinitionLimitExceeded: the subscription is already at its limit, and a
role definition outlives the resource group, so every run added to a total that
cannot grow.

None was needed. The built-in Azure Device Registry Contributor already carries
microsoft.deviceregistry/namespaces/credentials/policies/issueCertificate/action
as a data action, and the DPS identity is granted that role on the namespace
anyway for enrollment writes. The custom role and its lookup are gone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Linking reported LinkableResourceNotReady on every attempt for eleven minutes.
It was not replication: an S1 hub cannot be linked to an ADR namespace at all.
The previous model created a GEN2 hub for this path and that part was right, so
the generation is restored. Hubs provisioned without certificate management stay
on S1, which is what they were already getting by default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
'az iot hub create --sku' rejects GEN2 outside preview builds of the azure-iot
extension, which the removed version pin used to guarantee. The hub is now
created through ARM like the DPS, which also drops the last dependency on a
preview extension build. Hubs provisioned without certificate management stay on
S1, as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01-preview is not registered for IotHubs in the regions this provisions
into; 2026-05-01-preview is, and is the version the preview extension used for
the same resource.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A GEN2 hub carries the ADR namespace, and the identity it reaches that namespace
with, in its own properties: creating one without them fails IH400913, "Invalid
DeviceRegistry properties for Gen2 SKU". So the namespace and the identity have
to exist before the hub, not after it, and the identity has to be user-assigned
because the hub names it by resource id.

Creating the hub also validates that the identity can reach the namespace, so
the grant is made and waited for first.

This is the part of the previous model that was right, and dropping it is what
left the hub unlinkable. What has genuinely changed is the DPS, which is created
plain and attached to the namespace afterwards.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The GEN2 hub passed validation and then failed asynchronously, ending at
provisioningState=Failed after four minutes. A hub registers itself with the
namespace as it provisions, which is a write, and its identity only held Azure
Device Registry Contributor, which reads. Azure Device Registry Onboarding
carries namespaces/write and is granted alongside it, as the previous model did.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
provisioningState alone does not carry a reason, so an asynchronous failure
reported nothing but the word 'Failed'. The resource is now included in the
error, which is where the reason is recorded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hub reached state ActivationFailed, asynchronously and without a reason.
It was being created with both a system-assigned and a user-assigned identity
and with a minimum TLS version, none of which the previous model set for this
path: it created a GEN2 hub carrying the user-assigned identity alone. That
shape is restored.

The hub therefore has no identity of its own, so the grant that named it is
gone. In its place the namespace identity receives the two roles ADR documents
as needed against a hub it is attached to, Contributor and IoT Hub Registry
Contributor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Activating a GEN2 hub is work the IoT Hub service does on its own behalf, not as
any identity we create, so its application needs access to the group holding the
namespace. That grant was dropped as part of the previous model; without it the
hub is accepted, activates for several minutes and then settles at
state=ActivationFailed without saying why.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A GEN2 hub wired to an ADR namespace cannot be built from a plain ARM request:
the request is accepted and the hub then settles at state=ActivationFailed,
asynchronously and without a reason, whatever combination of identity, TLS
setting and Device Registry properties it is given. The CLI call the previous
model used does produce a working hub, so it is kept as it was.

That call needs --sku GEN2, --ns-resource-id and --ns-identity-id, which ship
only in preview builds of the azure-iot extension, so the version pin comes
back. It is now pinned for the hub, not for the retired 'az iot adr' command
group: the ADR resources are still created through ARM.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hub could never activate. ADR reported why once the CLI surfaced the
underlying error: "Linking a messaging endpoint of type
'Microsoft.Devices/IotHubs' requires the namespace to have at least one
provisioning endpoint of type 'Microsoft.Devices/provisioningServices'".

A hub is a messaging endpoint and adds itself to the namespace while it
activates, so the namespace has to have a DPS attached first. The DPS is
therefore created and linked before the hub, and the link submits only the
provisioning endpoint: submitting a messaging endpoint here would duplicate what
the hub does for itself. Every endpoint also has to carry an inbound caller
identity, which ADR rejects the link without.

The role grants follow the same split, since each one needs its resource to
exist: the namespace and DPS grants before the link, the hub grants after the
hub.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The wait insisted every endpoint reach a terminal state before looking at any of
them. A namespace can hold an endpoint that never carries a linkingState at all,
so a failed link burned the full fifteen minutes and then reported a timeout
instead of the failure, and the retry that exists for exactly that case never
ran. A failure now ends the attempt at once, and the endpoint's error is
reported in full rather than by code alone.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The link reported that the DPS 'could not be read and may not be ready', for
eleven minutes. It reads the DPS as the namespace identity, and only the other
direction was being waited for, so the attempt could start against a grant that
was not yet effective.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Live-validated against the C SDK CSR e2e pipeline (ci-c-e2e-csr, cert-management provisioning, centraluseuap), 19 runs. Not yet green; progress and the remaining blocker below.

Baseline. That pipeline was already failing on every run against master, at az iot dps create --ns-resource-id with IH500019 — the failure this PR exists to fix.

Fixed and confirmed by the service, in order of discovery:

  1. az iot dps create --mi-system-assigned is preview-only; the stable extension rejects it. DPS identity is set through ARM at creation (it cannot be patched later — IH400158).
  2. 2026-11-02-preview is not registered for namespaces in eastus; it is in centraluseuap.
  3. Custom role for certificate issuance hit RoleDefinitionLimitExceeded. Not needed — built-in Azure Device Registry Contributor already carries the issueCertificate data action.
  4. An S1 hub is not linkable. Certificate management needs a GEN2 hub, whose --sku GEN2 / --ns-resource-id arguments are preview-only, so the extension pin is restored — for the hub, not for the retired az iot adr group.
  5. A GEN2 hub built from a plain ARM request is accepted and then settles at state=ActivationFailed; the CLI call produces a working one.
  6. Ordering: ADR refuses a messaging endpoint unless the namespace already has a provisioning one, so the DPS is attached before the hub is created, and every endpoint must carry inboundCallerIdentity.

Remaining blocker. Attaching the DPS as a provisioning endpoint fails 12 times over 12 minutes with:

LinkableResourceNotReady: The linked resource ".../provisioningServices/<dps>" could not be read and may not be ready. Verify it is available, then resubmit the request.

The DPS is provisioningState=Succeeded before linking. The namespace identity holds Contributor on the DPS and the DPS identity holds Contributor + Azure Device Registry Contributor on the namespace, and both are waited for until readable, so this does not look like propagation.

Open question for whoever owns the service: how is a DPS meant to be attached to an ADR namespace in this build — as a namespace provisioning endpoint as here, or still at DPS creation time? If at creation, IH500019 from the baseline needs explaining, since the namespace was valid at that point.

The provisioning changes up to that point are validated. The branch is not ready to merge until the DPS attach works.

Copilot AI added 4 commits September 5, 2026 03:26
Chasing each failure in isolation drifted this away from the harness that
already provisions the same topology, and the drift is what kept the link
failing. Three differences mattered:

- The namespace was created without the tag the harness puts on it. ADR reads
  it as a feature flag; without it the linking saga takes a different path and
  reports the resource it is linking as unreadable, which is the
  LinkableResourceNotReady the link could not get past.
- The hub was GEN2, carrying the namespace and a user-assigned identity in its
  own properties. That is the retired model. The harness creates a plain S1 hub
  with a system-assigned identity and attaches it like anything else.
- The hub and the DPS were attached in two steps, because a GEN2 hub attaches
  itself. Both endpoints go in one link.

The extension version pin goes with them: every ADR resource, and both resources
that take part in the link, are created through ARM, so nothing needs the
preview line. The grant to the IoT Hub first-party application goes too; it
existed only for GEN2 activation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hub api-version constant was removed when the hub briefly went back to being
created through the CLI, and creating it through ARM again left the request with
a bare 'api-version=', which ARM rejects. A URL without one is now refused
before it is sent, since an empty value otherwise looks like a working call
right up to the point ARM answers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two more things the reference harness does that this did not:

- DPS control-plane calls go to the regional host in the canary locations. The
  DPS manifest carrying the ADR linking surface is registered regionally there,
  so a DPS created through the global host does not come up with it.
- The namespace, its grants and the link are retried as one unit. The link runs
  as the namespace identity, and an identity that never becomes usable to it
  cannot be waited out; recreating the namespace mints a fresh one. The previous
  code exhausted its in-place retries and gave up, which is exactly where the
  reference recreates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Routing DPS to the regional host produced 'the Authorization header is missing'.
The CLI only infers a token audience for the hosts it knows, so a regional ARM
host got no header at all. Every ARM call now states it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Converged the provisioning onto the reference E2E harness for this feature rather than continuing to patch it. That was the right call and fixed several things, but the remaining blocker turns out to be environmental, not scripted.

Now matched to the reference (each had been a divergence of mine):

  • namespace carries the useMiSdk tag the harness sets;
  • plain S1 hub with a system-assigned identity, not GEN2 with the namespace in its properties;
  • hub and DPS attached in one link, both endpoints together, every endpoint carrying inboundCallerIdentity;
  • DPS control-plane calls routed to the regional host in canary locations, where its linking manifest is registered (and the token audience stated explicitly, or the regional host gets no auth header);
  • namespace recreated and re-granted if the in-place link retries exhaust.
  • The extension version pin is gone again: every ADR resource, and both resources in the link, are created through ARM.

Blocker. hub-1 still fails LinkableResourceNotReady ("could not be read") across 24 attempts and two freshly-created namespaces, roughly 35 minutes. Provider state in the subscription this pipeline runs in:

  • Microsoft.Devices registered preview features: none.
  • IotHubs api-versions stop at 2026-05-01-preview; the harness uses 2026-06-01-preview, which is not offered here.

So the IoT Hub resource provider in this subscription has no ADR-linking surface, which is also why the original az iot dps create --ns-resource-id failed IH500019 on master. No change to this module can work around that.

Needed: the ADR-linking preview feature registered on Microsoft.Devices for the subscription this pipeline uses, or the pipeline pointed at one that already has it. Happy to re-run once either is in place.

Local gates green throughout. Not ready to merge until the link succeeds end to end.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There is at least one confirmed runtime-breaking bug (missing Location parameter usage) plus a security posture regression risk (min TLS not set) that should be addressed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 4
  • Review effort level: Lite

Comment on lines +1911 to +1915
param(
[string]$DpsId
)

$Url = "$(Get-DpsArmHost -Location $Location)$($DpsId)?api-version=$($script:DpsControlPlaneApiVersion)"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and a real one. $Location was never bound, so Get-DpsArmHost received an empty value and returned the global host — the data-plane push went to the wrong host in exactly the canary locations the regional host exists for, silently rather than throwing. Parameter declared in c9937bd; the call site was already passing it.

Comment on lines +2708 to +2712
location = $AzureLocation
sku = @{ name = "S1"; capacity = 1 }
identity = @{ type = "SystemAssigned" }
properties = @{ disableLocalAuth = $false }
} | Out-Null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in c9937bd. The certificate-management hub now sets minTlsVersion = "1.2" like every other hub here instead of relying on the default. It was omitted while the hub was briefly GEN2 and being minimised to isolate an activation failure; the hub is plain S1 again, so there is no reason to leave it off.

Comment thread scripts/Azure.Iot.Sdk.Test.psm1 Outdated
Comment on lines +1613 to +1615
# Azure Device Registry Onboarding: namespaces/write, namespaces/credentials/*. A hub registers
# itself with the namespace as it provisions, so its identity needs to be able to write.
$script:AdrOnboardingRoleId = "547f7f0a-69c0-4807-bd9e-0321dfb66a84"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, removed in c9937bd. It became unused when the hub stopped reaching the namespace as its own identity; nothing is granted that role any more.

Comment thread scripts/Azure.Iot.Sdk.Test.psm1 Outdated
Comment on lines +1715 to +1717
Only the DPS is attached here. A hub is a messaging endpoint and adds itself while it activates,
which is also why this has to run BEFORE the hub is created: ADR refuses a messaging endpoint
unless the namespace already has a provisioning one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, the description was stale, fixed in c9937bd. It described an earlier arrangement that attached the DPS first and let the hub attach itself. Both endpoints now go in one write, because ADR refuses a messaging endpoint unless the namespace also has a provisioning one, and the description says so.

…vers

The push chose its ARM host from a $Location the function never declared, so the
value was always empty and the call went to the global host even in the canary
locations the regional host exists for. The parameter is now declared; the call
site was already passing it.

Also: the certificate-management hub sets the same minimum TLS version as every
other hub in this module rather than relying on the default; the Device Registry
Onboarding role id is gone, having become unused when the hub stopped reaching
the namespace as its own identity; and Connect-AdrNamespace's description says
what it now does, which is to attach the hub and the DPS in one write.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Answering the question directly: nothing in the module differs from the reference any more. The difference is the subscription.

Measured, not inferred:

Reference / feature owners This CSR pipeline
Subscription 53cd450b-… IOTDPS_TEST_DEV_1 aziotclb - Build System subscription
Identity service connection iot-dps-arm-test-mi the SDK build OIDC identity
Region centraluseuap centraluseuap (set on the test branch)

The owners' pipeline hardcodes that subscription, and their harness documents it as the default and as a prerequisite (Contributor + User Access Administrator on it).

The pipeline identity can see exactly one subscription — the build subscription — and az account set on the DPS test subscription returns "doesn't exist in cloud 'AzureCloud'", i.e. no access. In that build subscription:

  • Microsoft.Devices registered preview features: none;
  • IotHubs api-versions stop at 2026-05-01-preview.

So the IoT Hub resource provider there has no ADR-linking surface, which is why hub-1 reports LinkableResourceNotReady across 24 attempts and two freshly created namespaces, and why the original az iot dps create --ns-resource-id failed IH500019 on master. The reference is sufficient for its owners because it runs where that surface exists.

Ask, either one:

  1. enable the ADR-linking preview on Microsoft.Devices in the build subscription; or
  2. give this pipeline's identity Contributor + User Access Administrator on 53cd450b-… and point AZURE_SUBSCRIPTION_ID at it.

Test branch test/adr-cert-authority-psm1 in the SDK repo is reset to just two changes (psm1 URL, region) and re-runs clean once either is in place.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It makes substantial changes to live Azure provisioning/linking behavior (including new ARM/DPS REST flows) and the PR notes live subscription validation is still outstanding.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

scripts/Azure.Iot.Sdk.Test.psm1:1814

  • The reconcile path comment says the namespace is created without tags, but New-AdrNamespace always sets tags = $script:AdrNamespaceTags. The guard itself is fine, but the rationale is now misleading; please update it to reflect that tags may be absent on some namespaces (e.g., pre-existing ones) rather than on those created by this module.
  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants