diff --git a/azext_iot/_help.py b/azext_iot/_help.py index ce8b2ac4f..715828e63 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -840,8 +840,11 @@ "iot device send-d2c-message" ] = """ type: command - short-summary: Send an mqtt device-to-cloud message. - The command supports sending messages with application and system properties. + short-summary: | + Send an mqtt device-to-cloud message. + The command supports sending messages with application and system properties. + + Note: The command only works for symmetric key auth (SAS) based devices examples: - name: Basic usage text: az iot device send-d2c-message -n {iothub_name} -d {device_id} @@ -863,7 +866,8 @@ While the device simulation is running, the device will automatically receive and acknowledge cloud-to-device (c2d) messages. For mqtt simulation, all c2d messages will be acknowledged with completion. For http simulation c2d acknowledgement is based on user - selection which can be complete, reject or abandon. + selection which can be complete, reject or abandon. Additionally, mqtt simulation is only + supported for symmetric key auth (SAS) based devices Note: The command by default will set content-type to application/json and content-encoding to utf-8. This can be overriden. diff --git a/azext_iot/common/shared.py b/azext_iot/common/shared.py index 1513cc0f2..ce89fc919 100644 --- a/azext_iot/common/shared.py +++ b/azext_iot/common/shared.py @@ -56,6 +56,16 @@ class DeviceAuthType(Enum): x509_ca = "x509_ca" +class DeviceAuthApiType(Enum): + """ + Hub Device Authorization type. + """ + + sas = "sas" + selfSigned = "selfSigned" + certificateAuthority = "certificateAuthority" + + class KeyType(Enum): """ Shared private key. diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index f98af95e2..61ebaa58f 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -28,6 +28,7 @@ SettleType, RenewKeyType, IoTHubStateType, + DeviceAuthApiType, ) from azext_iot.iothub.providers.discovery import IotHubDiscovery from azext_iot.common.utility import ( @@ -259,21 +260,21 @@ def _assemble_auth(auth_method, pk, sk): ) auth = None - if auth_method in [DeviceAuthType.shared_private_key.name, "sas"]: + if auth_method in [DeviceAuthType.shared_private_key.name, DeviceAuthApiType.sas.value]: auth = AuthenticationMechanism( - symmetric_key=SymmetricKey(primary_key=pk, secondary_key=sk), type="sas" + symmetric_key=SymmetricKey(primary_key=pk, secondary_key=sk), type=DeviceAuthApiType.sas.value ) - elif auth_method in [DeviceAuthType.x509_thumbprint.name, "selfSigned"]: + elif auth_method in [DeviceAuthType.x509_thumbprint.name, DeviceAuthApiType.selfSigned.value]: if not pk: raise ValueError("primary thumbprint required with selfSigned auth") auth = AuthenticationMechanism( x509_thumbprint=X509Thumbprint( primary_thumbprint=pk, secondary_thumbprint=sk ), - type="selfSigned", + type=DeviceAuthApiType.selfSigned.value, ) - elif auth_method in [DeviceAuthType.x509_ca.name, "certificateAuthority"]: - auth = AuthenticationMechanism(type="certificateAuthority") + elif auth_method in [DeviceAuthType.x509_ca.name, DeviceAuthApiType.certificateAuthority.value]: + auth = AuthenticationMechanism(type=DeviceAuthApiType.certificateAuthority.value) else: raise ValueError("Authorization method {} invalid.".format(auth_method)) return auth @@ -306,7 +307,7 @@ def update_iot_device_custom( auth_type = instance["authentication"]["type"] if auth_method is not None: if auth_method == DeviceAuthType.shared_private_key.name: - auth = "sas" + auth = DeviceAuthApiType.sas.value if (primary_key and not secondary_key) or ( not primary_key and secondary_key ): @@ -314,7 +315,7 @@ def update_iot_device_custom( instance["authentication"]["symmetricKey"]["primaryKey"] = primary_key instance["authentication"]["symmetricKey"]["secondaryKey"] = secondary_key elif auth_method == DeviceAuthType.x509_thumbprint.name: - auth = "selfSigned" + auth = DeviceAuthApiType.selfSigned.value if not any([primary_thumbprint, secondary_thumbprint]): raise CLIError( "primary or secondary Thumbprint required with selfSigned auth" @@ -328,13 +329,13 @@ def update_iot_device_custom( "secondaryThumbprint" ] = secondary_thumbprint elif auth_method == DeviceAuthType.x509_ca.name: - auth = "certificateAuthority" + auth = DeviceAuthApiType.certificateAuthority.value else: raise ValueError("Authorization method {} invalid.".format(auth_method)) instance["authentication"]["type"] = auth # if no new auth_method is provided, validate secondary auth arguments and update accordingly - elif auth_type == "sas": + elif auth_type == DeviceAuthApiType.sas.value: if any([primary_thumbprint, secondary_thumbprint]): raise ValueError( "Device authorization method {} does not support primary or secondary thumbprints.".format( @@ -346,7 +347,7 @@ def update_iot_device_custom( if secondary_key: instance["authentication"]["symmetricKey"]["secondaryKey"] = secondary_key - elif auth_type == "selfSigned": + elif auth_type == DeviceAuthApiType.selfSigned.value: if any([primary_key, secondary_key]): raise ValueError( "Device authorization method {} does not support primary or secondary keys.".format( @@ -476,7 +477,7 @@ def iot_device_key_regenerate( auth_type=auth_type_dataplane, ) device = _iot_device_show(target, device_id) - if device["authentication"]["type"] != "sas": + if device["authentication"]["type"] != DeviceAuthApiType.sas.value: raise CLIError("Device authentication should be of type sas") pk = device["authentication"]["symmetricKey"]["primaryKey"] @@ -867,15 +868,15 @@ def _handle_module_update_params(parameters): def _parse_auth(parameters): - valid_auth = ["sas", "selfSigned", "certificateAuthority"] + valid_auth = [DeviceAuthApiType.sas.value, DeviceAuthApiType.selfSigned.value, DeviceAuthApiType.certificateAuthority.value] auth = parameters["authentication"].get("type") if auth not in valid_auth: raise CLIError("authentication.type must be one of {}".format(valid_auth)) pk = sk = None - if auth == "sas": + if auth == DeviceAuthApiType.sas.value: pk = parameters["authentication"]["symmetricKey"]["primaryKey"] sk = parameters["authentication"]["symmetricKey"]["secondaryKey"] - elif auth == "selfSigned": + elif auth == DeviceAuthApiType.selfSigned.value: pk = parameters["authentication"]["x509Thumbprint"]["primaryThumbprint"] sk = parameters["authentication"]["x509Thumbprint"]["secondaryThumbprint"] if not any([pk, sk]): @@ -1924,7 +1925,7 @@ def iot_get_sas_token( ) return { - "sas": _iot_build_sas_token( + DeviceAuthApiType.sas.value: _iot_build_sas_token( cmd, hub_name, device_id, @@ -2022,13 +2023,13 @@ def _build_device_or_module_connection_string(entity, key_type="primary"): ) auth = entity["authentication"] auth_type = auth["type"].lower() - if auth_type == "sas": + if auth_type == DeviceAuthApiType.sas.value.lower(): key = "SharedAccessKey={}".format( auth["symmetricKey"]["primaryKey"] if key_type == "primary" else auth["symmetricKey"]["secondaryKey"] ) - elif auth_type in ["certificateauthority", "selfsigned"]: + elif auth_type in [DeviceAuthApiType.certificateAuthority.value.lower(), DeviceAuthApiType.selfSigned.value.lower()]: key = "x509=true" else: raise CLIError("Unable to form target connection string") @@ -2128,6 +2129,10 @@ def _iot_device_send_message( import ssl import os + device = _iot_device_show(target, device_id) + if device and device.get("authentication", {}).get("type", "") != DeviceAuthApiType.sas.value: + raise CLIError('D2C send message command only supports symmetric key auth (SAS) based devices') + msgs = [] if properties: properties = validate_key_value_pairs(properties) @@ -2514,6 +2519,11 @@ def http_wrap(target, device_id, generator): try: if protocol_type == ProtocolType.mqtt.name: + + device = _iot_device_show(target, device_id) + if device and device.get("authentication", {}).get("type", "") != DeviceAuthApiType.sas.value: + raise CLIError('MQTT simulation is only supported for symmetric key auth (SAS) based devices') + wrap = mqtt_client_wrap( target=target, device_id=device_id, diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index a60ed08a6..8f7e44721 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -14,6 +14,7 @@ from azure.cli.core.commands import AzCliCommand from azure.cli.core.mock import DummyCli from azext_iot.tests.generators import generate_generic_id +from azext_iot.common.shared import DeviceAuthApiType path_iot_hub_service_factory = "azext_iot._factory.iot_hub_service_factory" path_service_client = "msrest.service_client.ServiceClient.send" @@ -26,6 +27,7 @@ path_iot_hub_monitor_events_entrypoint = ( "azext_iot.operations.hub._iot_hub_monitor_events" ) +path_iot_device_show = "azext_iot.operations.hub._iot_device_show" hub_entity = "myhub.azure-devices.net" instance_name = generate_generic_id() @@ -137,6 +139,72 @@ def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) +@pytest.fixture() +def fixture_iot_device_show_sas(mocker): + device = mocker.patch(path_iot_device_show) + device.return_value = { + "authentication": { + "symmetricKey": { + "primaryKey": "test_pk", + "secondaryKey": "test_sk" + }, + "type": DeviceAuthApiType.sas.value, + "x509Thumbprint": { + "primaryThumbprint": None, + "secondaryThumbprint": None + } + }, + "capabilities": { + "iotEdge": False + }, + "cloudToDeviceMessageCount": 0, + "connectionState": "Disconnected", + "connectionStateUpdatedTime": "2021-05-27T00:36:11.2861732Z", + "deviceId": "Test_Device_1", + "etag": "ODgxNTgwOA==", + "generationId": "637534345627501371", + "hub": "test-iot-hub.azure-devices.net", + "lastActivityTime": "2021-05-27T00:18:16.3154299Z", + "status": "enabled", + "statusReason": None, + "statusUpdatedTime": "0001-01-01T00:00:00Z" + } + return device + + +@pytest.fixture() +def fixture_self_signed_device_show_self_signed(mocker): + device = mocker.patch(path_iot_device_show) + device.return_value = { + "authentication": { + "symmetricKey": { + "primaryKey": "test_pk", + "secondaryKey": "test_sk" + }, + "type": DeviceAuthApiType.selfSigned.value, + "x509Thumbprint": { + "primaryThumbprint": None, + "secondaryThumbprint": None + } + }, + "capabilities": { + "iotEdge": False + }, + "cloudToDeviceMessageCount": 0, + "connectionState": "Disconnected", + "connectionStateUpdatedTime": "2021-05-27T00:36:11.2861732Z", + "deviceId": "Test_Device_1", + "etag": "ODgxNTgwOA==", + "generationId": "637534345627501371", + "hub": "test-iot-hub.azure-devices.net", + "lastActivityTime": "2021-05-27T00:18:16.3154299Z", + "status": "enabled", + "statusReason": None, + "statusUpdatedTime": "0001-01-01T00:00:00Z" + } + return device + + # TODO: To be deprecated asap. Leverage mocked_response fixture for this functionality. def build_mock_response( mocker=None, status_code=200, payload=None, headers=None, **kwargs diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index 3ae27c1cd..86412732e 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -35,7 +35,7 @@ mock_target, generate_cs, ) - +from azext_iot.common.shared import DeviceAuthApiType device_id = "mydevice" child_device_id = "child_device1" @@ -131,13 +131,13 @@ def test_device_create(self, serviceclient, req): assert body["capabilities"]["iotEdge"] == req["ee"] if req["auth"] == "shared_private_key": - assert body["authentication"]["type"] == "sas" + assert body["authentication"]["type"] == DeviceAuthApiType.sas.value elif req["auth"] == "x509_ca": - assert body["authentication"]["type"] == "certificateAuthority" + assert body["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") elif req["auth"] == "x509_thumbprint": - assert body["authentication"]["type"] == "selfSigned" + assert body["authentication"]["type"] == DeviceAuthApiType.selfSigned.value x509tp = body["authentication"]["x509Thumbprint"] assert x509tp["primaryThumbprint"] if req["stp"] is None: @@ -192,7 +192,7 @@ def generate_device_show(**kvp): "authentication": { "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": {"primaryThumbprint": None, "secondaryThumbprint": None}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, }, "capabilities": {"iotEdge": True}, "deviceId": device_id, @@ -244,7 +244,7 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): generate_device_show( authentication={ "symmetricKey": {"primaryKey": "", "secondaryKey": ""}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, } ) ), @@ -255,13 +255,13 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): "primaryThumbprint": "123", "secondaryThumbprint": "321", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ) ), ( generate_device_show( - authentication={"type": "certificateAuthority"}, + authentication={"type": DeviceAuthApiType.certificateAuthority.value}, etag=generate_generic_id(), ) ), @@ -286,10 +286,10 @@ def test_device_update(self, fixture_cmd, serviceclient, req): assert body["status"] == req["status"] assert body["capabilities"]["iotEdge"] == req["capabilities"]["iotEdge"] assert req["authentication"]["type"] == body["authentication"]["type"] - if req["authentication"]["type"] == "certificateAuthority": + if req["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value: assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") - elif req["authentication"]["type"] == "selfSigned": + elif req["authentication"]["type"] == DeviceAuthApiType.selfSigned.value: assert body["authentication"]["x509Thumbprint"]["primaryThumbprint"] assert body["authentication"]["x509Thumbprint"]["secondaryThumbprint"] @@ -320,7 +320,7 @@ def test_device_update(self, fixture_cmd, serviceclient, req): ( generate_device_show( authentication={ - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": "123", @@ -337,7 +337,7 @@ def test_device_update(self, fixture_cmd, serviceclient, req): ( generate_device_show( authentication={ - "type": "certificateAuthority", + "type": DeviceAuthApiType.certificateAuthority.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": None, @@ -356,7 +356,7 @@ def test_device_update(self, fixture_cmd, serviceclient, req): ( generate_device_show( authentication={ - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": "123", @@ -389,7 +389,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): assert instance["statusReason"] == arg["status_reason"] if arg["auth_method"]: if arg["auth_method"] == "shared_private_key": - assert instance["authentication"]["type"] == "sas" + assert instance["authentication"]["type"] == DeviceAuthApiType.sas.value instance["authentication"]["symmetricKey"]["primaryKey"] == arg[ "primary_key" ] @@ -397,7 +397,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): "secondary_key" ] if arg["auth_method"] == "x509_thumbprint": - assert instance["authentication"]["type"] == "selfSigned" + assert instance["authentication"]["type"] == DeviceAuthApiType.selfSigned.value if arg["primary_thumbprint"]: instance["authentication"]["x509Thumbprint"][ "primaryThumbprint" @@ -407,7 +407,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): "secondaryThumbprint" ] = arg["secondary_thumbprint"] if arg["auth_method"] == "x509_ca": - assert instance["authentication"]["type"] == "certificateAuthority" + assert instance["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value @pytest.mark.parametrize( "req, arg, exp", @@ -437,7 +437,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): ( generate_device_show( authentication={ - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": "123", @@ -474,7 +474,7 @@ def test_iot_device_custom_invalid_args(self, serviceclient, req, arg, exp): "primaryThumbprint": "", "secondaryThumbprint": "", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ), CLIError, @@ -511,7 +511,7 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): "authentication", { "symmetricKey": {"primaryKey": "123", "secondaryKey": "321"}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, }, ) test_side_effect = [ @@ -737,13 +737,13 @@ def test_device_module_create(self, serviceclient, req): assert body["moduleId"] == req["module_id"] if req["auth"] == "shared_private_key": - assert body["authentication"]["type"] == "sas" + assert body["authentication"]["type"] == DeviceAuthApiType.sas.value elif req["auth"] == "x509_ca": - assert body["authentication"]["type"] == "certificateAuthority" + assert body["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") elif req["auth"] == "x509_thumbprint": - assert body["authentication"]["type"] == "selfSigned" + assert body["authentication"]["type"] == DeviceAuthApiType.selfSigned.value x509tp = body["authentication"]["x509Thumbprint"] assert x509tp["primaryThumbprint"] if req["stp"] is None: @@ -783,7 +783,7 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): generate_device_module_show( authentication={ "symmetricKey": {"primaryKey": "", "secondaryKey": ""}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, }, etag=generate_generic_id(), ) @@ -795,13 +795,13 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): "primaryThumbprint": "123", "secondaryThumbprint": "321", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ) ), ( generate_device_module_show( - authentication={"type": "certificateAuthority"} + authentication={"type": DeviceAuthApiType.certificateAuthority.value} ) ), ], @@ -830,10 +830,10 @@ def test_device_module_update(self, serviceclient, req): assert body["moduleId"] == req["moduleId"] assert not body.get("capabilities") assert req["authentication"]["type"] == body["authentication"]["type"] - if req["authentication"]["type"] == "certificateAuthority": + if req["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value: assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") - elif req["authentication"]["type"] == "selfSigned": + elif req["authentication"]["type"] == DeviceAuthApiType.selfSigned.value: assert body["authentication"]["x509Thumbprint"]["primaryThumbprint"] assert body["authentication"]["x509Thumbprint"]["secondaryThumbprint"] @@ -851,7 +851,7 @@ def test_device_module_update(self, serviceclient, req): "primaryThumbprint": "", "secondaryThumbprint": "", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ), CLIError, @@ -2001,7 +2001,7 @@ def test_generate_sas_token(self): class TestDeviceSimulate: @pytest.fixture(params=[204]) - def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): + def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_iot_device_show_sas): service_client = mocker.patch(path_service_client) service_client.return_value = build_mock_response(mocker, request.param, {}) return service_client @@ -2123,6 +2123,12 @@ def test_device_simulate_mqtt_error(self, mqttclient_generic_error): fixture_cmd, device_id, hub_name=mock_target["entity"] ) + def test_device_simulate_mqtt_non_sas_device_error(self, fixture_ghcs, fixture_self_signed_device_show_self_signed): + with pytest.raises(CLIError): + subject.iot_simulate_device( + fixture_cmd, device_id, hub_name=mock_target["entity"] + ) + @pytest.mark.skipif( not validate_min_python_version(3, 5, exit_on_fail=False),