diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index f5e90b25774..abfd685fbeb 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.0.0b21 ++++++++++++++++ +* Added the ``az quantum workspace user list`` command to list the users with access to an Azure Quantum workspace. + 1.0.0b20 +++++++++++++++ * Added the ``az quantum workspace user create`` and ``az quantum workspace user delete`` commands to manage user access to an Azure Quantum workspace. diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index c32a0a8ae7c..cd9960c4cd3 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -346,6 +346,25 @@ short-summary: Manage users of an Azure Quantum workspace. """ +helps['quantum workspace user list'] = """ + type: command + short-summary: List the users with access to an Azure Quantum workspace. + long-summary: >- + Lists the user principals (excluding groups and service principals) assigned the 'Quantum + Workspace Data Contributor' role at the scope of the given (or current) workspace. Use + '--role' to filter by a different role. + examples: + - name: List all users with access to a workspace. + text: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace + - name: List the users granted a specific role. + text: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace --role Reader + - name: Include users whose access is inherited from the resource group or subscription. + text: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace --include-inherited +""" + helps['quantum workspace user create'] = """ type: command short-summary: Grant a user, group, or service principal access to an Azure Quantum workspace. diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index ee196271938..99931b1cba8 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -70,8 +70,9 @@ def load_arguments(self, _): # pylint: disable=too-many-locals order_type = CLIArgumentType(options_list=['--order'], help='How to order the list: `asc` or `desc`') assignee_type = CLIArgumentType(options_list=['--assignee'], help='Represents a user, group, or service principal. Supported formats: object id, user sign-in name, or service principal name.') assignee_object_id_type = CLIArgumentType(options_list=['--assignee-object-id'], help="Use this parameter instead of '--assignee' to bypass Graph API invocation in case of insufficient privileges. This parameter only works with object ids for users, groups, service principals, and managed identities. For managed identities use the principal id. For service principals, use the object id and not the app id.") - role_type = CLIArgumentType(options_list=['--role'], help="Role name or id. For 'create', the role granted to the user; for 'delete', the role assignment to remove. Defaults to the 'Quantum Workspace Data Contributor' role.") + role_type = CLIArgumentType(options_list=['--role'], help="Role name or id. For 'create', the role to grant; for 'delete', the assignment to remove; for 'list', the role to filter by. All default to the 'Quantum Workspace Data Contributor' role.") assignee_principal_type_type = CLIArgumentType(options_list=['--assignee-principal-type'], arg_type=get_enum_type(['User', 'Group', 'ServicePrincipal', 'ForeignGroup']), help="Use with '--assignee-object-id' to avoid errors caused by propagation latency in Microsoft Graph.") + include_inherited_type = CLIArgumentType(options_list=['--include-inherited'], help='If specified, also list role assignments inherited from the parent resource group and subscription scopes.') with self.argument_context('quantum workspace') as c: c.argument('workspace_name', workspace_name_type) @@ -92,6 +93,9 @@ def load_arguments(self, _): # pylint: disable=too-many-locals with self.argument_context('quantum workspace user create') as c: c.argument('assignee_principal_type', assignee_principal_type_type) + with self.argument_context('quantum workspace user list') as c: + c.argument('include_inherited', include_inherited_type) + with self.argument_context('quantum target') as c: c.argument('workspace_name', workspace_name_type) c.argument('target_id', target_id_type) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index c95c447df7a..74e5b8441d6 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -56,6 +56,18 @@ def transform_jobs(results): return [transform_job(job) for job in results] +def transform_users(results): + def one(result): + return OrderedDict([ + ('Principal Id', result.get('principalId')), + ('Principal Name', result.get('principalName')), + ('Principal Type', result.get('principalType')), + ('Role', result.get('roleDefinitionName')), + ('Scope', result.get('scope')) + ]) + return [one(result) for result in results] + + def transform_offerings(offerings): def one(offering): return OrderedDict([ @@ -139,6 +151,7 @@ def load_command_table(self, _): with self.command_group('quantum workspace user', workspace_ops) as u: u.command('create', 'add_user', validator=validate_workspace_info) u.command('delete', 'remove_user', validator=validate_workspace_info, confirmation=True) + u.command('list', 'list_users', validator=validate_workspace_info, table_transformer=transform_users) with self.command_group('quantum target', target_ops) as t: t.command('list', 'list', validator=validate_workspace_info, table_transformer=transform_targets) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index e672fd689e4..d1456c608ef 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -497,3 +497,17 @@ def remove_user(cmd, resource_group_name=None, workspace_name=None, assignee=Non scope = _get_workspace_resource_id(info) role = role or QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID return delete_role_assignments(cmd, role=role, scope=scope, assignee=assignee, assignee_object_id=assignee_object_id) + + +def list_users(cmd, resource_group_name=None, workspace_name=None, assignee=None, assignee_object_id=None, role=None, include_inherited=False): + """ + List the users with access to an Azure Quantum workspace. + """ + from azure.cli.command_modules.role.custom import list_role_assignments + + info = WorkspaceInfo(cmd, resource_group_name, workspace_name) + scope = _get_workspace_resource_id(info) + role = role or QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID + assignments = list_role_assignments(cmd, assignee=assignee, assignee_object_id=assignee_object_id, role=role, scope=scope, include_inherited=include_inherited) + # Match the Quantum portal, which lists only user principals (not groups or service principals). + return [assignment for assignment in assignments if assignment.get("principalType") == "User"] diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 25522300e16..6b148e96062 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -7,6 +7,8 @@ import pytest import unittest import time +from types import SimpleNamespace +from unittest.mock import patch from azure.cli.testsdk.scenario_tests import AllowLargeResponse, live_only from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer) @@ -15,7 +17,7 @@ from ..._version_check_helper import check_version from datetime import datetime from ...__init__ import CLI_REPORTED_VERSION -from ...operations.workspace import _validate_storage_account, _autoadd_providers, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX +from ...operations.workspace import _validate_storage_account, _autoadd_providers, list_users, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -312,6 +314,11 @@ def test_workspace_user(self): self.check("ends_with(roleDefinitionId, 'c1410b24-3e69-4857-8f86-4d0a2e603250')", True) ]) + # list users and verify the new assignment appears + self.cmd(f'az quantum workspace user list -g {test_resource_group} --workspace-name {test_workspace_temp} -o json', checks=[ + self.check(f"length([?principalId=='{test_object_id}'])", 1) + ]) + # remove access using the object id and an explicit role self.cmd(f'az quantum workspace user delete -g {test_resource_group} --workspace-name {test_workspace_temp} --assignee-object-id {test_object_id} --role c1410b24-3e69-4857-8f86-4d0a2e603250 --yes') @@ -324,6 +331,65 @@ def test_workspace_user(self): self.check("properties.provisioningState", "Deleting") ]) + def test_list_users_scopes_to_workspace(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + assignments = [{"principalId": "oid", "principalType": "User", "roleDefinitionName": "Quantum Workspace Data Contributor"}] + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=assignments) as list_role_assignments: + cmd = SimpleNamespace(cli_ctx=object()) + result = list_users(cmd, "rg", "ws") + + expected_scope = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" + list_role_assignments.assert_called_once_with(cmd, assignee=None, assignee_object_id=None, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=False) + assert result == assignments + + def test_list_users_include_inherited(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=[]) as list_role_assignments: + cmd = SimpleNamespace(cli_ctx=object()) + list_users(cmd, "rg", "ws", include_inherited=True) + + expected_scope = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" + list_role_assignments.assert_called_once_with(cmd, assignee=None, assignee_object_id=None, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=True) + + def test_list_users_role_override(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=[]) as list_role_assignments: + cmd = SimpleNamespace(cli_ctx=object()) + list_users(cmd, "rg", "ws", role="Reader") + + expected_scope = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" + list_role_assignments.assert_called_once_with(cmd, assignee=None, assignee_object_id=None, role="Reader", scope=expected_scope, include_inherited=False) + + def test_list_users_excludes_groups_and_service_principals(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + assignments = [ + {"principalId": "u", "principalType": "User"}, + {"principalId": "g", "principalType": "Group"}, + {"principalId": "sp", "principalType": "ServicePrincipal"}, + ] + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=assignments): + cmd = SimpleNamespace(cli_ctx=object()) + result = list_users(cmd, "rg", "ws") + + assert result == [{"principalId": "u", "principalType": "User"}] + + def test_transform_users(self): + from ...commands import transform_users + rows = transform_users([{ + "principalId": "oid", + "principalName": "user@contoso.com", + "principalType": "User", + "roleDefinitionName": "Quantum Workspace Data Contributor", + "scope": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" + }]) + assert rows[0]["Principal Name"] == "user@contoso.com" + assert rows[0]["Principal Type"] == "User" + assert rows[0]["Role"] == "Quantum Workspace Data Contributor" + # @pytest.fixture(autouse=True) # def _pass_fixtures(self, capsys): # self.capsys = capsys diff --git a/src/quantum/setup.py b/src/quantum/setup.py index 22a1659670f..ea438d3bff6 100644 --- a/src/quantum/setup.py +++ b/src/quantum/setup.py @@ -17,7 +17,7 @@ # This version should match the latest entry in HISTORY.rst # Also, when updating this, please review the version used by the extension to # submit requests, which can be found at './azext_quantum/__init__.py' -VERSION = '1.0.0b20' +VERSION = '1.0.0b21' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers