From 6acf6932059ec90118f6fb014f2df5787e6beffa Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 23 May 2022 12:38:36 +0800 Subject: [PATCH 1/3] msgraph --- doc/microsoft_graph_client.md | 84 +++++++++++++++++++ .../cli/command_modules/role/__init__.py | 2 +- .../command_modules/role/_client_factory.py | 2 +- .../role/{msgrpah => _msgrpah}/__init__.py | 0 .../{msgrpah => _msgrpah}/_graph_client.py | 7 ++ .../{msgrpah => _msgrpah}/_graph_objects.py | 0 .../cli/command_modules/role/commands.py | 2 +- .../azure/cli/command_modules/role/custom.py | 2 +- .../role/tests/latest/test_role.py | 2 +- .../latest/test_role_commands_thru_mock.py | 2 +- 10 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 doc/microsoft_graph_client.md rename src/azure-cli/azure/cli/command_modules/role/{msgrpah => _msgrpah}/__init__.py (100%) rename src/azure-cli/azure/cli/command_modules/role/{msgrpah => _msgrpah}/_graph_client.py (97%) rename src/azure-cli/azure/cli/command_modules/role/{msgrpah => _msgrpah}/_graph_objects.py (100%) diff --git a/doc/microsoft_graph_client.md b/doc/microsoft_graph_client.md new file mode 100644 index 00000000000..a2a15e69f87 --- /dev/null +++ b/doc/microsoft_graph_client.md @@ -0,0 +1,84 @@ +# `GraphClient` - Microsoft Graph API client + +Azure CLI has been migrated to Microsoft Graph API for Azure Active Directory operations. A lightweight client `azure.cli.command_modules.role._msgrpah.GraphClient` is developed for calling Microsoft Graph API. + +## Create a `GraphClient` instance + +`GraphClient` should NEVER be instantiated directly, but always through the client factory `azure.cli.command_modules.role.graph_client_factory`. + +```py +# Correct! +from azure.cli.command_modules.role import graph_client_factory +graph_client = graph_client_factory(cli_ctx) + +# Wrong! +from azure.cli.command_modules.role._msgrpah import GraphClient +graph_client = GraphClient(cli_ctx) +``` + +## Example + +Here is a full example `graph_demo.py`. It does several tasks: + +1. Create an application and a service principal for this application. +2. Resolve service principal's object ID from its service principal name. +3. Delete the application. + +```py +from azure.cli.core import get_default_cli + +from knack.log import get_logger + +logger = get_logger(__name__) + + +def create_application_and_service_principal(cli_ctx, display_name): + """Create an application with display_name. Then create a service principal for this application.""" + from azure.cli.command_modules.role import graph_client_factory, GraphError + graph_client = graph_client_factory(cli_ctx) + try: + body = {"displayName": display_name} + app = graph_client.application_create(body) + sp = graph_client.service_principal_create({"appId": app['appId']}) + return app, sp + except GraphError as ex: + logger.exception(ex) + + +def delete_application(cli_ctx, object_id): + """Delete an application specified by its object ID.""" + from azure.cli.command_modules.role import graph_client_factory, GraphError + graph_client = graph_client_factory(cli_ctx) + try: + graph_client.application_delete(object_id) + except GraphError as ex: + logger.exception(ex) + + +def resolve_service_principal_id(cli_ctx, service_principal_name): + """Resolve service principal's object ID from its service principal name.""" + from azure.cli.command_modules.role import graph_client_factory, GraphError + graph_client = graph_client_factory(cli_ctx) + try: + service_principals = graph_client.service_principal_list( + filter="servicePrincipalNames/any(c:c eq '{}')".format(service_principal_name)) + return service_principals[0]['id'] if service_principals else None + except GraphError as ex: + logger.exception(ex) + + +def main(): + cli_ctx = get_default_cli() + + app, sp = create_application_and_service_principal(cli_ctx, 'azure-cli-test') + print('Created application {} and service principal {}'.format(app['id'], sp['id'])) + + resolved_sp_object_id = resolve_service_principal_id(cli_ctx, sp['appId']) + print('appId {} is resolved to service principal {}'.format(app['appId'], resolved_sp_object_id)) + + delete_application(cli_ctx, app['id']) + + +if __name__ == "__main__": + main() +``` diff --git a/src/azure-cli/azure/cli/command_modules/role/__init__.py b/src/azure-cli/azure/cli/command_modules/role/__init__.py index 6903a10ea12..26594c3ec41 100644 --- a/src/azure-cli/azure/cli/command_modules/role/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/role/__init__.py @@ -7,7 +7,7 @@ from azure.cli.core import AzCommandsLoader from azure.cli.core.profiles import ResourceType from ._client_factory import _graph_client_factory as graph_client_factory -from .msgrpah import GraphError +from ._msgrpah import GraphError class RoleCommandsLoader(AzCommandsLoader): diff --git a/src/azure-cli/azure/cli/command_modules/role/_client_factory.py b/src/azure-cli/azure/cli/command_modules/role/_client_factory.py index ac02b92eb20..b351f6c795a 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/role/_client_factory.py @@ -17,6 +17,6 @@ def _auth_client_factory(cli_ctx, scope=None): def _graph_client_factory(cli_ctx, **_): - from .msgrpah import GraphClient + from ._msgrpah import GraphClient client = GraphClient(cli_ctx) return client diff --git a/src/azure-cli/azure/cli/command_modules/role/msgrpah/__init__.py b/src/azure-cli/azure/cli/command_modules/role/_msgrpah/__init__.py similarity index 100% rename from src/azure-cli/azure/cli/command_modules/role/msgrpah/__init__.py rename to src/azure-cli/azure/cli/command_modules/role/_msgrpah/__init__.py diff --git a/src/azure-cli/azure/cli/command_modules/role/msgrpah/_graph_client.py b/src/azure-cli/azure/cli/command_modules/role/_msgrpah/_graph_client.py similarity index 97% rename from src/azure-cli/azure/cli/command_modules/role/msgrpah/_graph_client.py rename to src/azure-cli/azure/cli/command_modules/role/_msgrpah/_graph_client.py index e4158be25aa..e4e33f719d5 100644 --- a/src/azure-cli/azure/cli/command_modules/role/msgrpah/_graph_client.py +++ b/src/azure-cli/azure/cli/command_modules/role/_msgrpah/_graph_client.py @@ -13,6 +13,13 @@ # pylint: disable=redefined-builtin, too-many-public-methods class GraphClient: + """A lightweight Microsoft Graph API client. + + GraphClient should NEVER be instantiated directly, but always through the client factory + azure.cli.command_modules.role.graph_client_factory. + + For full documentation, see doc/microsoft_graph_client.md in this repo. + """ def __init__(self, cli_ctx): self.cli_ctx = cli_ctx self.tenant = Profile(cli_ctx).get_login_credentials()[2] diff --git a/src/azure-cli/azure/cli/command_modules/role/msgrpah/_graph_objects.py b/src/azure-cli/azure/cli/command_modules/role/_msgrpah/_graph_objects.py similarity index 100% rename from src/azure-cli/azure/cli/command_modules/role/msgrpah/_graph_objects.py rename to src/azure-cli/azure/cli/command_modules/role/_msgrpah/_graph_objects.py diff --git a/src/azure-cli/azure/cli/command_modules/role/commands.py b/src/azure-cli/azure/cli/command_modules/role/commands.py index fb45a35ec34..675444d2e8b 100644 --- a/src/azure-cli/azure/cli/command_modules/role/commands.py +++ b/src/azure-cli/azure/cli/command_modules/role/commands.py @@ -47,7 +47,7 @@ def _transform_graph_object(result): def graph_err_handler(ex): # Convert GraphError to CLIError that can be printed - from .msgrpah import GraphError + from ._msgrpah import GraphError if isinstance(ex, GraphError): from knack.util import CLIError raise CLIError(ex) diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index f2d005fb7ec..fc718ec882e 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -29,7 +29,7 @@ from azure.cli.core.util import get_file_json, shell_safe_json_parse, is_guid from ._client_factory import _auth_client_factory, _graph_client_factory from ._multi_api_adaptor import MultiAPIAdaptor -from .msgrpah import GraphError, set_object_properties +from ._msgrpah import GraphError, set_object_properties # ARM RBAC's principalType USER = 'User' diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py index c366ac89458..b9907ad98c1 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py @@ -17,7 +17,7 @@ from azure.cli.testsdk.scenario_tests import AllowLargeResponse from azure.cli.core.profiles import ResourceType, get_sdk from azure.cli.testsdk import ScenarioTest, LiveScenarioTest, ResourceGroupPreparer, KeyVaultPreparer -from azure.cli.command_modules.role.msgrpah import GraphError +from azure.cli.command_modules.role._msgrpah import GraphError from ..util import retry from .test_graph import GraphScenarioTestBase diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role_commands_thru_mock.py index 9aea2529237..bc8f0f9850f 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role_commands_thru_mock.py @@ -22,7 +22,7 @@ list_service_principal_owners, list_application_owners, delete_role_assignments) -from azure.cli.command_modules.role.msgrpah import GraphError +from azure.cli.command_modules.role._msgrpah import GraphError from azure.cli.core.mock import DummyCli # pylint: disable=line-too-long From 71007af2df1179943ffdbfa44694bd5dced17e18 Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 23 May 2022 15:02:26 +0800 Subject: [PATCH 2/3] _msgrpah --- .../azure/cli/command_modules/role/tests/latest/test_role.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py index b9907ad98c1..f549a10b64e 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py @@ -352,7 +352,7 @@ def _test_role_assignment(assignee_object_id, assignee_principal_type=None, grap if assignee_principal_type: # No graph call - with mock.patch('azure.cli.command_modules.role.msgrpah._graph_client.GraphClient.directory_object_get_by_ids') \ + with mock.patch('azure.cli.command_modules.role._msgrpah._graph_client.GraphClient.directory_object_get_by_ids') \ as directory_object_get_by_ids_mock: self.cmd( 'role assignment create --assignee-object-id {object_id} ' @@ -367,7 +367,7 @@ def _test_role_assignment(assignee_object_id, assignee_principal_type=None, grap mock_response.status_code = 403 mock_response.reason = 'Forbidden for url: https://...' with mock.patch( - 'azure.cli.command_modules.role.msgrpah._graph_client.GraphClient.directory_object_get_by_ids', + 'azure.cli.command_modules.role._msgrpah._graph_client.GraphClient.directory_object_get_by_ids', side_effect=GraphError('403', mock_response)): self.cmd('role assignment create --assignee-object-id {object_id} --role Reader -g {rg}') else: From 2581e6f3aed98940b5933b1a748e36a8f736f62a Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Mon, 23 May 2022 18:48:10 +0800 Subject: [PATCH 3/3] add more details --- doc/microsoft_graph_client.md | 47 +++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/doc/microsoft_graph_client.md b/doc/microsoft_graph_client.md index a2a15e69f87..3d8586d2415 100644 --- a/doc/microsoft_graph_client.md +++ b/doc/microsoft_graph_client.md @@ -1,4 +1,4 @@ -# `GraphClient` - Microsoft Graph API client +# `GraphClient` - Microsoft Graph API client Azure CLI has been migrated to Microsoft Graph API for Azure Active Directory operations. A lightweight client `azure.cli.command_modules.role._msgrpah.GraphClient` is developed for calling Microsoft Graph API. @@ -16,7 +16,50 @@ from azure.cli.command_modules.role._msgrpah import GraphClient graph_client = GraphClient(cli_ctx) ``` -## Example +## Request + +The underlying Microsoft Graph API is exposed as `GraphClient` methods. For example, [Create application](https://docs.microsoft.com/en-us/graph/api/application-post-applications) API corresponds to `application_create`. + +The order of the verb ("create") and the noun ("application") is inverted to keep alignment with Azure CLI commands like `az ad app create` and old Python SDK `azure-graphrbac` methods like `graph_client.applications.create`. This makes it easier to find a method/API from the new `GraphClient` and migrate to it: + +```diff +- graph_client.applications.create(app_create_param) ++ graph_client.application_create(body) +``` + +The `body` argument for the request should be a `dict` object as defined by the underlying API. For example, [Create application](https://docs.microsoft.com/en-us/graph/api/application-post-applications) takes a `dict` object defined by [application resource type](https://docs.microsoft.com/en-us/graph/api/resources/application). + +For example, to create an application with certain `displayName`: + +```py +body = {"displayName": display_name} +app = graph_client.application_create(body) +``` + +## Response + +Like `body`, the response is also a `dict` object, returned by the underlying API. The `dict` object is deserialized from the JSON response, **unchanged**, meaning any client-side manipulation defined by REST API spec (such as flattening) doesn't take place. + +For example, to get an application's object ID: + +```py +app_object_id = app['id'] +``` + +## Error + +All `GraphClient` methods raise an `azure.cli.command_modules.role.GraphError` exception if the underlying APIs return a status code >= 400. + +Say we catch the exception as `ex`. `str(ex)` gives the `error.message` field of the response JSON. `ex.response` gives the raw response, as a `requests.models.Response` object. + +For example, to retrieve the error message and status code: + +```py +message = str(ex) +status_code = ex.response.status_code +``` + +## A full example Here is a full example `graph_demo.py`. It does several tasks: