Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ Release History

* For managed-identity links, the linked hub's hostname is auto-resolved from the IoT Hub's ``deviceHostName`` (with classic fallback for V1 hubs). CLI validates that the appropriate identity type is enabled on the DPS resource before creating a managed-identity link.

**Bug fixes (0.31.0b1 bug bash follow-ups)**

* ``az iot dps linked-hub create`` now rejects linking the same IoT Hub more than once to the same DPS, regardless of hostname type or authentication method. Previously the second link silently created a duplicate ``iotHubs`` entry under a different hostname (e.g. classic vs ``.device.``), leaving the DPS in an ambiguous state. Use ``az iot dps linked-hub delete`` to remove the existing link before re-linking.

* ``az iot hub device-identity connection-string show`` and ``az iot hub module-identity connection-string show`` now reject ``--hostname-type service``. Devices and modules cannot authenticate against the service endpoint; use ``auto``, ``device`` or ``classic`` instead.

* ``az iot hub generate-sas-token`` gains a ``--hostname-type`` parameter and now produces audience-correct SAS tokens for TLS 1.3 hubs:

- Hub-level SAS (no ``-d``) defaults to ``--hostname-type auto`` which resolves to the service hostname on GWv2 hubs and the classic hostname on V1 hubs. ``service``, ``device`` and ``classic`` are explicitly selectable.
- Device-level SAS (``-d``) and module-level SAS (``-d -m``) default to ``auto`` which resolves to the device hostname on GWv2 hubs and the classic hostname on V1 hubs. ``--hostname-type service`` is rejected.
- **Behavior change:** on GWv2 hubs the ``sr=`` audience for device/module SAS tokens now points at the device endpoint (``<hub>.device.azure-devices.net/devices/...``). Hub-side enforcement of audience-to-endpoint match is rolling out, so consumers should treat this as the correct default.

0.29.0
+++++++++++++++

Expand Down
6 changes: 6 additions & 0 deletions azext_iot/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,12 @@
text: >
az iot hub generate-sas-token --connection-string
'HostName=myhub.azure-devices.net;DeviceId=mydevice;ModuleId=mymodule;SharedAccessKeyName=iothubowner;SharedAccessKey=12345'
- name: Generate a device SAS token whose audience targets the TLS 1.3 device endpoint of a GWv2 hub.
text: >
az iot hub generate-sas-token -d {device_id} -n {iothub_name} --hostname-type device
- name: Generate an IoT Hub SAS token whose audience explicitly targets the TLS 1.3 service endpoint.
text: >
az iot hub generate-sas-token -n {iothub_name} --hostname-type service
"""

helps[
Expand Down
15 changes: 15 additions & 0 deletions azext_iot/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,21 @@ def load_arguments(self, _):
"'service' uses the TLS 1.3 service hostname (errors if not GWv2).",
)

with self.argument_context("iot hub generate-sas-token") as context:
context.argument(
"hostname_type",
options_list=["--hostname-type", "--ht"],
arg_type=get_enum_type(HostnameType),
default=HostnameType.AUTO.value,
help="Type of hostname to use as the SAS token audience (sr=). "
"'auto' uses the TLS 1.3 device hostname for device/module-scoped tokens "
"and the service hostname for hub-scoped tokens (on GWv2 hubs). "
"'classic' always uses the default hostname. "
"'device' uses the TLS 1.3 device hostname (errors if not GWv2). "
"'service' uses the TLS 1.3 service hostname (errors if not GWv2 or if "
"a device/module is specified).",
)

with self.argument_context("iot hub job") as context:
context.argument("job_id", options_list=["--job-id"], help="IoT Hub job Id.")
context.argument(
Expand Down
40 changes: 31 additions & 9 deletions azext_iot/core/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,30 @@ def _resolve_linked_hub_hostname(hub, hostname_type="auto"):
return device_hostname or hub["properties"]["hostName"]


def _linked_hub_hostname(hub):
"""Return the raw hostname for a linked-hub entry, checking ``hostName``,
``connectionString`` and ``name`` in turn. Returns "" if none is available.
"""
hostname = hub.get("hostName", "") or ""
if not hostname:
cs = hub.get("connectionString", "") or ""
for part in cs.split(";"):
if part.lower().startswith("hostname="):
hostname = part.split("=", 1)[1]
break
if not hostname:
hostname = hub.get("name", "") or ""
return hostname


def _warn_mixed_endpoint_types(linked_hubs):
"""Warn if DPS dynamic allocation references hubs with mixed hostname types."""
types = set()
for hub in linked_hubs:
# Only check hubs participating in allocation
if hub.get("applyAllocationPolicy") is False:
continue
hostname = hub.get("hostName", "")
if not hostname:
cs = hub.get("connectionString", "")
for part in cs.split(";"):
if part.lower().startswith("hostname="):
hostname = part.split("=", 1)[1]
break
if not hostname:
hostname = hub.get("name", "")
hostname = _linked_hub_hostname(hub)
parts = hostname.split(".")
if len(parts) > 1 and parts[1] == "device":
types.add("device")
Expand Down Expand Up @@ -478,6 +486,20 @@ def iot_dps_linked_hub_create(
if allocation_weight is not None:
linked_hub_entry["allocationWeight"] = allocation_weight

# Reject duplicate hub linking (same hub under any hostname type) — bug bash 0.31.0b1
new_short_name = _linked_hub_hostname(linked_hub_entry).split(".")[0].lower()
existing_short_names = {
_linked_hub_hostname(h).split(".")[0].lower()
for h in dps["properties"]["iotHubs"]
}
existing_short_names.discard("")
if new_short_name and new_short_name in existing_short_names:
raise InvalidArgumentValueError(
f"IoT Hub '{new_short_name}' is already linked to DPS '{dps_name}'. "
"Remove the existing link with 'az iot dps linked-hub delete' "
"before re-linking under a different hostname type or authentication method."
)

dps["properties"]["iotHubs"].append(linked_hub_entry)

# Warn if linked hubs have mixed hostname types (device + classic)
Expand Down
39 changes: 36 additions & 3 deletions azext_iot/operations/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -2240,6 +2240,7 @@ def iot_get_sas_token(
module_id=None,
auth_type_dataplane=None,
connection_string=None,
hostname_type=HostnameType.AUTO.value,
):
key_type = key_type.lower()
policy_name = policy_name.lower()
Expand All @@ -2256,6 +2257,12 @@ def iot_get_sas_token(
raise ArgumentUsageError(
"You are unable to get sas token for module without device information."
)
if device_id and hostname_type == HostnameType.SERVICE.value:
raise InvalidArgumentValueError(
"Hostname type 'service' is not supported for device or module SAS tokens. "
"Devices and modules cannot authenticate against the service endpoint. "
"Use 'auto', 'device', or 'classic' instead."
)

if connection_string:
return {
Expand All @@ -2277,6 +2284,7 @@ def iot_get_sas_token(
resource_group_name,
login,
auth_type_dataplane,
hostname_type,
).generate_sas_token()
}

Expand Down Expand Up @@ -2330,6 +2338,7 @@ def _iot_build_sas_token(
resource_group_name=None,
login=None,
auth_type_dataplane=None,
hostname_type=HostnameType.AUTO.value,
):
from azext_iot.common._azure import (
parse_iot_device_connection_string,
Expand All @@ -2352,6 +2361,18 @@ def _iot_build_sas_token(
policy = None
key = None

# Resolve the hostname used to build the SAS audience (`sr=`).
# Device/module scopes default to the device endpoint on GWv2 hubs; hub-level
# scope defaults to the service endpoint. The `service` hostname type was
# already rejected for device/module scopes in iot_get_sas_token.
auto_tls_key = "deviceHostName" if device_id else "serviceHostName"
if login:
resolved_host = _transform_hostname(target["entity"], hostname_type)
else:
resolved_host = _resolve_hostname_by_type(
target, hostname_type, auto_tls_key=auto_tls_key
)
Comment on lines +2368 to +2374

if device_id:
logger.info(
'Obtaining device "%s" details from registry, using IoT Hub policy "%s"',
Expand All @@ -2365,7 +2386,7 @@ def _iot_build_sas_token(
entity=module, key_type=key_type
)
uri = "{}/devices/{}/modules/{}".format(
target["entity"], device_id, module_id
resolved_host, device_id, module_id
)
try:
parsed_module_cs = parse_iot_device_module_connection_string(module_cs)
Expand All @@ -2378,7 +2399,7 @@ def _iot_build_sas_token(
device_cs = _build_device_or_module_connection_string(
entity=device, key_type=key_type
)
uri = "{}/devices/{}".format(target["entity"], device_id)
uri = "{}/devices/{}".format(resolved_host, device_id)
try:
parsed_device_cs = parse_iot_device_connection_string(device_cs)
except ValueError as e:
Expand All @@ -2387,7 +2408,7 @@ def _iot_build_sas_token(

key = parsed_device_cs["SharedAccessKey"]
else:
uri = target["entity"]
uri = resolved_host
policy = target["policy"]
key = target["primarykey"] if key_type == "primary" else target["secondarykey"]

Expand Down Expand Up @@ -2468,6 +2489,12 @@ def iot_get_device_connection_string(
auth_type_dataplane=None,
hostname_type=HostnameType.AUTO.value,
):
if hostname_type == HostnameType.SERVICE.value:
raise InvalidArgumentValueError(
"Hostname type 'service' is not supported for device connection strings. "
"Devices cannot authenticate against the service endpoint. "
"Use 'auto', 'device', or 'classic' instead."
)
result = {}
discovery = IotHubDiscovery(cmd)
target = discovery.get_target(
Expand Down Expand Up @@ -2498,6 +2525,12 @@ def iot_get_module_connection_string(
auth_type_dataplane=None,
hostname_type=HostnameType.AUTO.value,
):
if hostname_type == HostnameType.SERVICE.value:
raise InvalidArgumentValueError(
"Hostname type 'service' is not supported for module connection strings. "
"Modules cannot authenticate against the service endpoint. "
"Use 'auto', 'device', or 'classic' instead."
)
result = {}
discovery = IotHubDiscovery(cmd)
target = discovery.get_target(
Expand Down
46 changes: 46 additions & 0 deletions azext_iot/tests/dps/core/test_dps_linked_hub_int.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,49 @@ def test_linked_hub_list_shows_hostname(provisioned_iot_dps_no_hub_module):
f"Should find linked hub with name '{device_hostname}'. Found: {[h['name'] for h in linked_hubs]}"
finally:
_cleanup_linked_hub(dps_name, dps_rg, device_hostname)


def test_linked_hub_create_rejects_duplicate_cross_hostname_type(provisioned_iot_dps_no_hub_module):
"""Bug-bash #7: linking the same hub a second time (with different --hostname-type) must fail.

Step 1: link hub as classic.
Step 2: try to re-link the same hub as device → expect non-zero exit code.
"""
dps_name = provisioned_iot_dps_no_hub_module["name"]
dps_rg = provisioned_iot_dps_no_hub_module["resourceGroup"]

gwv2_hub = _find_gwv2_hub(dps_rg)
if not gwv2_hub:
pytest.skip("No GWv2 hub available in resource group")

hub_name = gwv2_hub["name"]
classic_hostname = gwv2_hub["properties"]["hostName"]
device_hostname = gwv2_hub["properties"]["deviceHostName"]

try:
# Step 1: first link succeeds
cli.invoke(
f"iot dps linked-hub create --dps-name {dps_name} -g {dps_rg} "
f"--hub-name {hub_name} --hostname-type classic"
)

# Step 2: re-linking the same hub under a different hostname type must fail.
result = cli.invoke(
f"iot dps linked-hub create --dps-name {dps_name} -g {dps_rg} "
f"--hub-name {hub_name} --hostname-type device"
)
assert not result.success(), \
"Re-linking the same hub under a different hostname type should be rejected"

# Verify only the original classic entry remains.
linked_hubs = cli.invoke(
f"iot dps linked-hub list --dps-name {dps_name} -g {dps_rg}"
).as_json()
names = [h["name"] for h in linked_hubs]
assert classic_hostname in names, \
f"Original classic link should still exist. Names: {names}"
assert device_hostname not in names, \
f"Device-hostname entry should NOT have been created. Names: {names}"
finally:
_cleanup_linked_hub(dps_name, dps_rg, classic_hostname)
_cleanup_linked_hub(dps_name, dps_rg, device_hostname)
Loading
Loading