Skip to content

Commit a2f5730

Browse files
feat: Support MI auth for DPS linked-hub (#813)
* feat: Support MI auth for DPS linked-hub * tweak code format * add validations to resolve comments --------- Co-authored-by: Yuelin Zhao <yuelinzhao@microsoft.com>
1 parent cdb578e commit a2f5730

7 files changed

Lines changed: 266 additions & 33 deletions

File tree

azext_iot/_params.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,11 +353,12 @@ def load_arguments(self, _):
353353
context.argument(
354354
"hostname_type",
355355
options_list=["--hostname-type", "--ht"],
356-
arg_type=get_enum_type(["auto", "classic", "service"]),
356+
arg_type=get_enum_type(HostnameType),
357357
default=HostnameType.AUTO.value,
358358
help="Type of hostname to use in the connection string. "
359-
"'auto' uses the TLS 1.3 service hostname on GWv2 hubs, classic otherwise. "
359+
"'auto' uses the TLS 1.3 device hostname on GWv2 hubs, classic otherwise. "
360360
"'classic' always uses the default hostname. "
361+
"'device' uses the TLS 1.3 device hostname (errors if not GWv2). "
361362
"'service' uses the TLS 1.3 service hostname (errors if not GWv2).",
362363
)
363364

@@ -369,12 +370,13 @@ def load_arguments(self, _):
369370
context.argument(
370371
"hostname_type",
371372
options_list=["--hostname-type", "--ht"],
372-
arg_type=get_enum_type(["auto", "classic", "device"]),
373+
arg_type=get_enum_type(HostnameType),
373374
default=HostnameType.AUTO.value,
374375
help="Type of hostname to use in the connection string. "
375376
"'auto' uses the TLS 1.3 device hostname on GWv2 hubs, classic otherwise. "
376377
"'classic' always uses the default hostname. "
377-
"'device' uses the TLS 1.3 device hostname (errors if not GWv2).",
378+
"'device' uses the TLS 1.3 device hostname (errors if not GWv2). "
379+
"'service' uses the TLS 1.3 service hostname (errors if not GWv2).",
378380
)
379381

380382
with self.argument_context("iot hub job") as context:

azext_iot/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@
5252
LRO_POLL_RETRIES = 10
5353

5454
IOTHUB_PREVIEW_API_VERSION = "2026-03-01-preview"
55+
IOT_HUB_DEFAULT_POLICY = "iothubowner"

azext_iot/core/_params.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222

2323
from .custom import KeyType, SimpleAccessRights
24-
from .shared import IotDpsSku, IotHubSku, AccessRightsDescription
24+
from .shared import IotDpsSku, IotHubSku, AccessRightsDescription, IotHubAuthenticationType
2525
from azure.cli.command_modules.iot._validators import (validate_policy_permissions,
2626
validate_retention_days,
2727
validate_fileupload_notification_max_delivery_count,
@@ -134,6 +134,26 @@ def load_arguments(self, _): # pylint: disable=too-many-statements
134134
help='Location of the IoT hub.',
135135
arg_group='IoT Hub Identifier',
136136
deprecate_info=c.deprecate(hide=True))
137+
c.argument('authentication_type',
138+
options_list=['--authentication-type', '--auth-type'],
139+
arg_type=get_enum_type(IotHubAuthenticationType),
140+
help='Authentication type for the linked IoT Hub. '
141+
"'KeyBased' uses a connection string. "
142+
"'SystemAssigned' uses the DPS system-assigned managed identity. "
143+
"'UserAssigned' uses a user-assigned managed identity.")
144+
c.argument('user_assigned_identity',
145+
options_list=['--user-assigned-identity', '--uai'],
146+
help='User-assigned managed identity resource ID. '
147+
'Required when authentication type is UserAssigned.')
148+
c.argument('hostname_type',
149+
options_list=['--hostname-type', '--ht'],
150+
arg_type=get_enum_type(["device", "classic"]),
151+
default="device",
152+
help="Type of IoT Hub hostname to use when linking. "
153+
"'device' uses the TLS 1.3 device hostname (hub.device.azure-devices.net). "
154+
"'classic' uses the classic hostname (hub.azure-devices.net). "
155+
"Falls back to classic if the hub does not support TLS 1.3. "
156+
"Only applies when --hub-name is provided.")
137157
c.argument('apply_allocation_policy',
138158
help='A boolean indicating whether to apply allocation policy to the IoT hub.',
139159
arg_type=get_three_state_flag())

azext_iot/core/custom.py

Lines changed: 107 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
BadRequestError,
1818
CLIInternalError,
1919
InvalidArgumentValueError,
20+
MutuallyExclusiveArgumentError,
2021
RequiredArgumentMissingError,
2122
ResourceNotFoundError,
2223
UnclassifiedUserFault,
@@ -29,6 +30,8 @@
2930
from knack.util import CLIError
3031

3132
from azext_iot._factory import iot_hub_service_factory, resource_service_factory
33+
from azext_iot.common._azure import IOT_SERVICE_CS_TEMPLATE
34+
from azext_iot.constants import IOT_HUB_DEFAULT_POLICY
3235
from azext_iot.common.certops import open_certificate
3336
from azext_iot.core.shared import (
3437
ADR_CONFIGURE_ROLES_ERROR_MSG,
@@ -41,6 +44,7 @@
4144
EndpointType,
4245
IdentityType,
4346
IotDpsSku,
47+
IotHubAuthenticationType,
4448
IotHubSku,
4549
ManagedServiceIdentityType,
4650
RenewKeyType,
@@ -78,6 +82,13 @@ def _get_resource_group_from_hub(hub):
7882
return hub["resourcegroup"]
7983

8084

85+
def _resolve_linked_hub_hostname(hub, hostname_type="device"):
86+
"""Resolve IoT Hub hostname for DPS linked hub based on hostname type."""
87+
if hostname_type == "device":
88+
return hub["properties"].get("deviceHostName") or hub["properties"]["hostName"]
89+
return hub["properties"]["hostName"]
90+
91+
8192
# CUSTOM METHODS FOR DPS
8293
def iot_dps_list(client, resource_group_name=None):
8394
if resource_group_name is None:
@@ -327,40 +338,110 @@ def iot_dps_linked_hub_create(
327338
connection_string=None,
328339
location=None,
329340
resource_group_name=None,
341+
authentication_type=None,
342+
user_assigned_identity=None,
343+
hostname_type="device",
330344
apply_allocation_policy=None,
331345
allocation_weight=None,
332346
no_wait=False
333347
):
334-
if not any([connection_string, hub_name]):
335-
raise RequiredArgumentMissingError("Please provide the IoT Hub name or connection string.")
336-
if not connection_string:
337-
# Get the connection string for the hub
338-
hub_client = iot_hub_service_factory(cmd.cli_ctx)
339-
connection_string = iot_hub_show_connection_string(
340-
hub_client, hub_name=hub_name, resource_group_name=hub_resource_group
341-
)['connectionString']
348+
is_mi = authentication_type in (
349+
IotHubAuthenticationType.SYSTEM_ASSIGNED.value,
350+
IotHubAuthenticationType.USER_ASSIGNED.value,
351+
)
342352

343-
if not location:
344-
# Parse out hub name from connection string if needed
353+
# MI based Hub Linking in DPS
354+
if is_mi:
355+
if connection_string:
356+
raise MutuallyExclusiveArgumentError(
357+
"--connection-string cannot be used with --authentication-type. "
358+
"Use --hub-name instead for managed identity authentication."
359+
)
345360
if not hub_name:
346-
try:
347-
hub_name = re.search(r"hostname=(.[^\;\.]+)?", connection_string, re.IGNORECASE).group(1)
348-
except AttributeError:
349-
raise InvalidArgumentValueError("Please provide a valid IoT Hub connection string.")
361+
raise RequiredArgumentMissingError(
362+
"Please provide --hub-name for managed identity authentication."
363+
)
364+
if authentication_type == IotHubAuthenticationType.USER_ASSIGNED.value and not user_assigned_identity:
365+
raise RequiredArgumentMissingError(
366+
"--user-assigned-identity is required when --authentication-type is UserAssigned."
367+
)
350368

351369
hub_client = iot_hub_service_factory(cmd.cli_ctx)
352-
try:
353-
location = iot_hub_get(cmd, hub_client, hub_name=hub_name, resource_group_name=hub_resource_group)["location"]
354-
except CLIError:
355-
raise RequiredArgumentMissingError("Please provide the IoT Hub location.")
370+
hub = iot_hub_get(cmd, hub_client, hub_name=hub_name, resource_group_name=hub_resource_group)
371+
host_name = _resolve_linked_hub_hostname(hub, hostname_type)
372+
373+
# Validate MI is enabled on DPS
374+
resource_group_name = _ensure_dps_resource_group_name(client, resource_group_name, dps_name)
375+
dps = iot_dps_get(client, dps_name, resource_group_name)
376+
identity = dps.get("identity") or {}
377+
identity_type = identity.get("type", "None") if isinstance(identity, dict) else "None"
378+
if authentication_type == IotHubAuthenticationType.SYSTEM_ASSIGNED.value and "SystemAssigned" not in identity_type:
379+
raise InvalidArgumentValueError(
380+
f"System-assigned managed identity is not enabled on DPS '{dps_name}'. "
381+
"Please enable it before linking with SystemAssigned authentication."
382+
)
383+
if authentication_type == IotHubAuthenticationType.USER_ASSIGNED.value and "UserAssigned" not in identity_type:
384+
raise InvalidArgumentValueError(
385+
f"User-assigned managed identity is not configured on DPS '{dps_name}'. "
386+
"Please assign a user identity before linking with UserAssigned authentication."
387+
)
356388

357-
resource_group_name = _ensure_dps_resource_group_name(client, resource_group_name, dps_name)
389+
linked_hub_entry = {
390+
"location": location or hub["location"],
391+
"authenticationType": authentication_type,
392+
"hostName": host_name,
393+
}
394+
if user_assigned_identity:
395+
linked_hub_entry["selectedUserAssignedIdentityResourceId"] = user_assigned_identity
358396

359-
dps = iot_dps_get(client, dps_name, resource_group_name)
360-
dps["properties"]["iotHubs"].append({"connectionString": connection_string,
361-
"location": location,
362-
"applyAllocationPolicy": apply_allocation_policy,
363-
"allocationWeight": allocation_weight})
397+
# KeyBased Hub Linking in DPS
398+
else:
399+
if not any([connection_string, hub_name]):
400+
raise RequiredArgumentMissingError("Please provide the IoT Hub name or connection string.")
401+
if not connection_string:
402+
hub_client = iot_hub_service_factory(cmd.cli_ctx)
403+
hub = iot_hub_get(cmd, hub_client, hub_name=hub_name, resource_group_name=hub_resource_group)
404+
host_name = _resolve_linked_hub_hostname(hub, hostname_type)
405+
location = location or hub["location"]
406+
# Build connection string with resolved hostname
407+
policies = iot_hub_policy_get(hub_client, hub_name, IOT_HUB_DEFAULT_POLICY,
408+
_get_resource_group_from_hub(hub))
409+
connection_string = IOT_SERVICE_CS_TEMPLATE.format(
410+
host_name, policies["keyName"], policies["primaryKey"]
411+
)
412+
else:
413+
if ".service.azure-devices" in connection_string.lower():
414+
raise InvalidArgumentValueError(
415+
"Service hostname is not supported for DPS hub linking. "
416+
"Use a connection string with device or classic hostname."
417+
)
418+
if not location:
419+
if not hub_name:
420+
try:
421+
hub_name = re.search(r"hostname=(.[^\;\.]+)?", connection_string, re.IGNORECASE).group(1)
422+
except AttributeError:
423+
raise InvalidArgumentValueError("Please provide a valid IoT Hub connection string.")
424+
425+
hub_client = iot_hub_service_factory(cmd.cli_ctx)
426+
try:
427+
location = iot_hub_get(cmd, hub_client, hub_name=hub_name, resource_group_name=hub_resource_group)["location"]
428+
except CLIError:
429+
raise RequiredArgumentMissingError("Please provide the IoT Hub location.")
430+
431+
resource_group_name = _ensure_dps_resource_group_name(client, resource_group_name, dps_name)
432+
dps = iot_dps_get(client, dps_name, resource_group_name)
433+
434+
linked_hub_entry = {
435+
"connectionString": connection_string,
436+
"location": location,
437+
}
438+
439+
if apply_allocation_policy is not None:
440+
linked_hub_entry["applyAllocationPolicy"] = apply_allocation_policy
441+
if allocation_weight is not None:
442+
linked_hub_entry["allocationWeight"] = allocation_weight
443+
444+
dps["properties"]["iotHubs"].append(linked_hub_entry)
364445

365446
if no_wait:
366447
return client.iot_dps_resource.begin_create_or_update(
@@ -374,8 +455,8 @@ def iot_dps_linked_hub_create(
374455
return iot_dps_linked_hub_list(client, dps_name, resource_group_name)
375456

376457

377-
def iot_dps_linked_hub_update(cmd, client, dps_name, linked_hub, resource_group_name=None, apply_allocation_policy=None,
378-
allocation_weight=None, no_wait=False):
458+
def iot_dps_linked_hub_update(cmd, client, dps_name, linked_hub, resource_group_name=None,
459+
apply_allocation_policy=None, allocation_weight=None, no_wait=False):
379460
if '.' not in linked_hub:
380461
hub_client = iot_hub_service_factory(cmd.cli_ctx)
381462
linked_hub = _get_iot_hub_hostname(hub_client, linked_hub)

azext_iot/core/shared.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ class IotDpsSku(Enum):
110110
S1 = "S1"
111111

112112

113+
class IotHubAuthenticationType(Enum):
114+
"""Authentication type for DPS linked IoT Hub."""
115+
116+
KEY_BASED = "KeyBased"
117+
SYSTEM_ASSIGNED = "SystemAssigned"
118+
USER_ASSIGNED = "UserAssigned"
119+
120+
113121
class AccessRightsDescription(str, Enum):
114122
"""DPS access rights."""
115123

azext_iot/operations/hub.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2992,9 +2992,17 @@ def _get_hub_connection_string(
29922992
]
29932993

29942994
if hostname_type == HostnameType.AUTO.value:
2995-
hostname = hub["properties"].get("serviceHostName") or hub["properties"]["hostName"]
2995+
hostname = hub["properties"].get("deviceHostName") or hub["properties"]["hostName"]
2996+
elif hostname_type == HostnameType.CLASSIC.value:
2997+
hostname = hub["properties"]["hostName"]
29962998
else:
2997-
hostname = _transform_hostname(hub["properties"]["hostName"], hostname_type)
2999+
key = "deviceHostName" if hostname_type == HostnameType.DEVICE.value else "serviceHostName"
3000+
hostname = hub["properties"].get(key)
3001+
if not hostname:
3002+
raise InvalidArgumentValueError(
3003+
f"The '{hostname_type}' hostname is not available for IoT Hub '{hub['name']}'. "
3004+
"This hostname type is only supported on GWv2 IoT Hubs."
3005+
)
29983006
cs_template = "HostName={};SharedAccessKeyName={};SharedAccessKey={}"
29993007
return [
30003008
cs_template.format(

0 commit comments

Comments
 (0)