From ad32f7c5e0e821e257004b71f8e68b1b9b142b3c Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 12 Aug 2026 11:30:44 -0700 Subject: [PATCH 01/15] Add `az quantum workspace user list` command to manage user access --- src/quantum/HISTORY.rst | 4 +++ src/quantum/azext_quantum/_help.py | 13 +++++++ src/quantum/azext_quantum/_params.py | 2 +- src/quantum/azext_quantum/commands.py | 13 +++++++ .../azext_quantum/operations/workspace.py | 11 ++++++ .../tests/latest/test_quantum_workspace.py | 34 ++++++++++++++++++- src/quantum/setup.py | 2 +- 7 files changed, 76 insertions(+), 3 deletions(-) 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..a8f0f28f3b6 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -346,6 +346,19 @@ short-summary: Manage users of an Azure Quantum workspace. """ +helps['quantum workspace user list'] = """ + type: command + short-summary: List the users, groups, and service principals with access to an Azure Quantum workspace. + examples: + - name: List all users with access to a workspace. + text: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace + - name: List the users assigned a specific role in a workspace. + text: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace \\ + --role "Quantum Workspace Data Contributor" +""" + 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..569c970ce14 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -70,7 +70,7 @@ 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 (both default to the 'Quantum Workspace Data Contributor' role); for 'list', an optional filter by 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.") with self.argument_context('quantum workspace') as c: 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..c82bf467c3f 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -497,3 +497,14 @@ 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): + """ + List the users, groups, and service principals 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) + return list_role_assignments(cmd, assignee=assignee, assignee_object_id=assignee_object_id, role=role, scope=scope) 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..9c4bfc1408b 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, 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,31 @@ 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", "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=None, scope=expected_scope) + assert result == assignments + + 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 From 0d612260cf15b0e17323acab5d2ceecfcb3df898 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 13 Aug 2026 12:09:11 -0700 Subject: [PATCH 02/15] Add support for `--include-inherited` flag in `az quantum workspace user list` command --- src/quantum/azext_quantum/_help.py | 3 +++ src/quantum/azext_quantum/_params.py | 4 ++++ src/quantum/azext_quantum/operations/workspace.py | 4 ++-- .../tests/latest/test_quantum_workspace.py | 12 +++++++++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index a8f0f28f3b6..1fcde254955 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -357,6 +357,9 @@ text: |- az quantum workspace user list -g MyResourceGroup -w MyWorkspace \\ --role "Quantum Workspace Data Contributor" + - 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'] = """ diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index 569c970ce14..bbd56dac25c 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -72,6 +72,7 @@ def load_arguments(self, _): # pylint: disable=too-many-locals 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 to grant; for 'delete', the assignment to remove (both default to the 'Quantum Workspace Data Contributor' role); for 'list', an optional filter by 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/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index c82bf467c3f..886cff4b4bb 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -499,7 +499,7 @@ def remove_user(cmd, resource_group_name=None, workspace_name=None, assignee=Non 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): +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, groups, and service principals with access to an Azure Quantum workspace. """ @@ -507,4 +507,4 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, assignee=None info = WorkspaceInfo(cmd, resource_group_name, workspace_name) scope = _get_workspace_resource_id(info) - return list_role_assignments(cmd, assignee=assignee, assignee_object_id=assignee_object_id, role=role, scope=scope) + return list_role_assignments(cmd, assignee=assignee, assignee_object_id=assignee_object_id, role=role, scope=scope, include_inherited=include_inherited) 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 9c4bfc1408b..a82eab67b60 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -340,9 +340,19 @@ def test_list_users_scopes_to_workspace(self): 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=None, scope=expected_scope) + list_role_assignments.assert_called_once_with(cmd, assignee=None, assignee_object_id=None, role=None, 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=None, scope=expected_scope, include_inherited=True) + def test_transform_users(self): from ...commands import transform_users rows = transform_users([{ From 874c81210d395426664f2c1237476a544bd91989 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 13 Aug 2026 17:47:53 -0700 Subject: [PATCH 03/15] update 'role' implementation --- src/quantum/azext_quantum/_help.py | 9 ++++++--- src/quantum/azext_quantum/_params.py | 2 +- .../azext_quantum/operations/workspace.py | 1 + .../tests/latest/test_quantum_workspace.py | 16 +++++++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 1fcde254955..2b63fa979a1 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -349,14 +349,17 @@ helps['quantum workspace user list'] = """ type: command short-summary: List the users, groups, and service principals with access to an Azure Quantum workspace. + long-summary: >- + By default, lists the principals assigned the 'Quantum Workspace Data Contributor' role (the + role granted by 'az quantum workspace user create') 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 assigned a specific role in a workspace. + - name: List the users granted a specific role. text: |- - az quantum workspace user list -g MyResourceGroup -w MyWorkspace \\ - --role "Quantum Workspace Data Contributor" + 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 diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index bbd56dac25c..99931b1cba8 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -70,7 +70,7 @@ 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 to grant; for 'delete', the assignment to remove (both default to the 'Quantum Workspace Data Contributor' role); for 'list', an optional filter by 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.') diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 886cff4b4bb..3cb30c127a2 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -507,4 +507,5 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, assignee=None info = WorkspaceInfo(cmd, resource_group_name, workspace_name) scope = _get_workspace_resource_id(info) + role = role or QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID return list_role_assignments(cmd, assignee=assignee, assignee_object_id=assignee_object_id, role=role, scope=scope, include_inherited=include_inherited) 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 a82eab67b60..9c1fbbdaa05 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -17,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, list_users, 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__), '..')) @@ -340,7 +340,7 @@ def test_list_users_scopes_to_workspace(self): 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=None, scope=expected_scope, include_inherited=False) + 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): @@ -351,7 +351,17 @@ def test_list_users_include_inherited(self): 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=None, scope=expected_scope, include_inherited=True) + 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_transform_users(self): from ...commands import transform_users From f8d6d7ac48ae4fc5d8391a23a716104686b42e33 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Fri, 14 Aug 2026 11:19:33 -0700 Subject: [PATCH 04/15] exclude groups and service principals --- src/quantum/azext_quantum/_help.py | 8 ++++---- .../azext_quantum/operations/workspace.py | 6 ++++-- .../tests/latest/test_quantum_workspace.py | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 2b63fa979a1..cd9960c4cd3 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -348,11 +348,11 @@ helps['quantum workspace user list'] = """ type: command - short-summary: List the users, groups, and service principals with access to an Azure Quantum workspace. + short-summary: List the users with access to an Azure Quantum workspace. long-summary: >- - By default, lists the principals assigned the 'Quantum Workspace Data Contributor' role (the - role granted by 'az quantum workspace user create') at the scope of the given (or current) - workspace. Use '--role' to filter by a different role. + 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: |- diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 3cb30c127a2..d1456c608ef 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -501,11 +501,13 @@ def remove_user(cmd, resource_group_name=None, workspace_name=None, assignee=Non 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, groups, and service principals with access to an Azure Quantum workspace. + 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 - return list_role_assignments(cmd, assignee=assignee, assignee_object_id=assignee_object_id, role=role, scope=scope, include_inherited=include_inherited) + 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 9c1fbbdaa05..6b148e96062 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -333,7 +333,7 @@ def test_workspace_user(self): def test_list_users_scopes_to_workspace(self): info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) - assignments = [{"principalId": "oid", "roleDefinitionName": "Quantum Workspace Data Contributor"}] + 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()) @@ -363,6 +363,20 @@ def test_list_users_role_override(self): 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([{ From 06fb090c1d0c7690545b04127e5dcc3271bf8c1f Mon Sep 17 00:00:00 2001 From: Konstantin Averkiev <45589614+kaverkiev@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:47:01 -0700 Subject: [PATCH 05/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/quantum/azext_quantum/_help.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index cd9960c4cd3..d362280d239 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -350,9 +350,9 @@ 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. + Lists user principals (excluding groups and service principals) assigned the 'Quantum Workspace Data Contributor' + role for the given (or current) workspace. By default, only assignments scoped to the workspace are shown; use + '--include-inherited' to include those inherited from the parent resource group/subscription. Use '--role' to filter by a different role. examples: - name: List all users with access to a workspace. text: |- From aca2e71994ce9179f79da819ab7679096d84e861 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 18 Aug 2026 11:08:56 -0700 Subject: [PATCH 06/15] Add action flag to `--include-inherited` parameter --- src/quantum/azext_quantum/_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index 99931b1cba8..e9cdf3eda58 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -72,7 +72,7 @@ def load_arguments(self, _): # pylint: disable=too-many-locals 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 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.') + include_inherited_type = CLIArgumentType(options_list=['--include-inherited'], action='store_true', 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) From 3834ec1dd975cb0e734add51e0e63215c53fd5c6 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 18 Aug 2026 16:03:10 -0700 Subject: [PATCH 07/15] Refactoring. Removed role, assignee,assignee_object_id params --- src/quantum/azext_quantum/_help.py | 11 ++++------- src/quantum/azext_quantum/_params.py | 4 ++-- .../azext_quantum/operations/workspace.py | 5 ++--- .../tests/latest/test_quantum_workspace.py | 18 ++++-------------- 4 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index d362280d239..65b7383566c 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -351,18 +351,15 @@ short-summary: List the users with access to an Azure Quantum workspace. long-summary: >- Lists user principals (excluding groups and service principals) assigned the 'Quantum Workspace Data Contributor' - role for the given (or current) workspace. By default, only assignments scoped to the workspace are shown; use - '--include-inherited' to include those inherited from the parent resource group/subscription. Use '--role' to filter by a different role. + role for the given (or current) workspace. By default this includes access inherited from the parent resource group + and subscription; pass '--include-inherited false' to list only assignments scoped directly to the workspace. 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. + - name: List only users assigned directly on the workspace (exclude inherited access). 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 + az quantum workspace user list -g MyResourceGroup -w MyWorkspace --include-inherited false """ helps['quantum workspace user create'] = """ diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index e9cdf3eda58..0315cac3e3f 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -8,7 +8,7 @@ import argparse from knack.arguments import CLIArgumentType from azure.cli.core.azclierror import InvalidArgumentValueError, CLIError -from azure.cli.core.commands.parameters import get_enum_type +from azure.cli.core.commands.parameters import get_enum_type, get_three_state_flag from azure.cli.core.util import shell_safe_json_parse @@ -72,7 +72,7 @@ def load_arguments(self, _): # pylint: disable=too-many-locals 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 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'], action='store_true', help='If specified, also list role assignments inherited from the parent resource group and subscription scopes.') + include_inherited_type = CLIArgumentType(options_list=['--include-inherited'], arg_type=get_three_state_flag(), help='Include role assignments inherited from the parent resource group and subscription. Enabled by default; use "--include-inherited false" to list only assignments scoped directly to the workspace.') with self.argument_context('quantum workspace') as c: c.argument('workspace_name', workspace_name_type) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index d1456c608ef..d090062fdd6 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -499,7 +499,7 @@ def remove_user(cmd, resource_group_name=None, workspace_name=None, assignee=Non 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): +def list_users(cmd, resource_group_name=None, workspace_name=None, include_inherited=True): """ List the users with access to an Azure Quantum workspace. """ @@ -507,7 +507,6 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, assignee=None 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) + assignments = list_role_assignments(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, 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 6b148e96062..af53eb32ae3 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -340,28 +340,18 @@ def test_list_users_scopes_to_workspace(self): 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) + list_role_assignments.assert_called_once_with(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=True) assert result == assignments - def test_list_users_include_inherited(self): + def test_list_users_can_exclude_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) + list_users(cmd, "rg", "ws", include_inherited=False) 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) + list_role_assignments.assert_called_once_with(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, 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) From b0ae63d9538b0cb39231c5c83f4fe9d6e641564a Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 18 Aug 2026 16:32:41 -0700 Subject: [PATCH 08/15] Remove comment --- src/quantum/azext_quantum/operations/workspace.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index d090062fdd6..7f35cfc586c 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -508,5 +508,4 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, include_inher info = WorkspaceInfo(cmd, resource_group_name, workspace_name) scope = _get_workspace_resource_id(info) assignments = list_role_assignments(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, 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"] From 661bf7e80ace1e7de6d69b30f18cd5c239d66f87 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 18 Aug 2026 17:28:24 -0700 Subject: [PATCH 09/15] add name and email --- src/quantum/azext_quantum/_help.py | 5 +-- src/quantum/azext_quantum/commands.py | 6 ++-- .../azext_quantum/operations/workspace.py | 34 ++++++++++++++++++- .../tests/latest/test_quantum_workspace.py | 30 ++++++++++++---- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 65b7383566c..431fd5637bf 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -351,8 +351,9 @@ short-summary: List the users with access to an Azure Quantum workspace. long-summary: >- Lists user principals (excluding groups and service principals) assigned the 'Quantum Workspace Data Contributor' - role for the given (or current) workspace. By default this includes access inherited from the parent resource group - and subscription; pass '--include-inherited false' to list only assignments scoped directly to the workspace. + role for the given (or current) workspace. Each user's Name and Email are resolved from Microsoft Graph. By default + this includes access inherited from the parent resource group and subscription; pass '--include-inherited false' to + list only assignments scoped directly to the workspace. examples: - name: List all users with access to a workspace. text: |- diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 74e5b8441d6..467c001995e 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -59,10 +59,10 @@ def transform_jobs(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')), + ('Name', result.get('displayName')), + ('Email', result.get('mail') or result.get('principalName')), ('Role', result.get('roleDefinitionName')), + ('Principal Id', result.get('principalId')), ('Scope', result.get('scope')) ]) return [one(result) for result in results] diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 7f35cfc586c..134198d4f54 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -28,6 +28,10 @@ from ..vendored_sdks.azure_mgmt_quantum.models import Provider, ApiKeys, WorkspaceResourceProperties, KeyType from .offerings import accept_terms, _get_publisher_and_offer_from_provider_id, _get_terms_from_marketplace, OFFER_NOT_AVAILABLE, PUBLISHER_NOT_AVAILABLE +from knack.log import get_logger + +logger = get_logger(__name__) + DEFAULT_WORKSPACE_LOCATION = 'westus' DEFAULT_STORAGE_SKU = 'Standard_LRS' DEFAULT_STORAGE_SKU_TIER = 'Standard' @@ -508,4 +512,32 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, include_inher info = WorkspaceInfo(cmd, resource_group_name, workspace_name) scope = _get_workspace_resource_id(info) assignments = list_role_assignments(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=scope, include_inherited=include_inherited) - return [assignment for assignment in assignments if assignment.get("principalType") == "User"] + users = [assignment for assignment in assignments if assignment.get("principalType") == "User"] + _fill_user_display_names(cmd, users) + return users + + +def _fill_user_display_names(cmd, users): + """ + Enrich user role assignments with the display name and email resolved from Microsoft Graph, + matching the Name and Email columns shown in the Quantum portal. Best-effort: if the lookup + fails (for example, the caller cannot read the directory), the principal name is used instead. + """ + principal_ids = {user["principalId"] for user in users if user.get("principalId")} + if not principal_ids: + return + + from azure.cli.command_modules.role.custom import _graph_client_factory, _get_object_stubs + + directory_objects = {} + try: + graph_client = _graph_client_factory(cmd.cli_ctx) + for obj in _get_object_stubs(graph_client, principal_ids): + directory_objects[obj.get("id")] = obj + except Exception as ex: # pylint: disable=broad-except + logger.warning("Could not resolve user display names from Microsoft Graph: %s", ex) + + for user in users: + obj = directory_objects.get(user.get("principalId"), {}) + user["displayName"] = obj.get("displayName") + user["mail"] = obj.get("mail") or obj.get("userPrincipalName") or user.get("principalName") 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 af53eb32ae3..fb1d210bd85 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -333,15 +333,19 @@ def test_workspace_user(self): 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"}] + assignments = [{"principalId": "oid", "principalName": "user@contoso.com", "principalType": "User", "roleDefinitionName": "Quantum Workspace Data Contributor"}] + stubs = [{"id": "oid", "displayName": "Contoso User", "mail": "user@contoso.com", "userPrincipalName": "user@contoso.com"}] 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: + patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=assignments) as list_role_assignments, \ + patch("azure.cli.command_modules.role.custom._graph_client_factory", return_value=object()), \ + patch("azure.cli.command_modules.role.custom._get_object_stubs", return_value=stubs): 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, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=True) - assert result == assignments + assert result[0]["displayName"] == "Contoso User" + assert result[0]["mail"] == "user@contoso.com" def test_list_users_can_exclude_inherited(self): info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) @@ -360,25 +364,37 @@ def test_list_users_excludes_groups_and_service_principals(self): {"principalId": "g", "principalType": "Group"}, {"principalId": "sp", "principalType": "ServicePrincipal"}, ] + stubs = [{"id": "u", "displayName": "User One", "mail": "u@contoso.com", "userPrincipalName": "u@contoso.com"}] with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ - patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=assignments): + patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=assignments), \ + patch("azure.cli.command_modules.role.custom._graph_client_factory", return_value=object()), \ + patch("azure.cli.command_modules.role.custom._get_object_stubs", return_value=stubs): cmd = SimpleNamespace(cli_ctx=object()) result = list_users(cmd, "rg", "ws") - assert result == [{"principalId": "u", "principalType": "User"}] + assert [user["principalId"] for user in result] == ["u"] + assert result[0]["displayName"] == "User One" def test_transform_users(self): from ...commands import transform_users rows = transform_users([{ "principalId": "oid", "principalName": "user@contoso.com", + "displayName": "Contoso User", + "mail": "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]["Name"] == "Contoso User" + assert rows[0]["Email"] == "user@contoso.com" assert rows[0]["Role"] == "Quantum Workspace Data Contributor" + assert rows[0]["Principal Id"] == "oid" + + # Email falls back to the principal name when Graph did not return a mail address. + fallback = transform_users([{"principalName": "fallback@contoso.com"}]) + assert fallback[0]["Email"] == "fallback@contoso.com" + assert fallback[0]["Name"] is None # @pytest.fixture(autouse=True) # def _pass_fixtures(self, capsys): From 470196d6d5bb84e6fe69c9f86e39998a779d847c Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 18 Aug 2026 18:12:18 -0700 Subject: [PATCH 10/15] Update release history for version 1.0.0b22 and add new command details --- src/quantum/HISTORY.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index abfd685fbeb..f652bc49cf8 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,10 +3,14 @@ Release History =============== -1.0.0b21 +1.0.0b22 +++++++++++++++ * Added the ``az quantum workspace user list`` command to list the users with access to an Azure Quantum workspace. +1.0.0b21 ++++++++++++++++ +* Added the ``az quantum job update`` command to update a submitted job's name, priority, and tags. + 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. From 0b931b513985f286070c6178ea8b1ddf44a666a3 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 19 Aug 2026 10:10:27 -0700 Subject: [PATCH 11/15] Add 'Time Created' field to user transformation and update version to 1.0.0b22 --- src/quantum/azext_quantum/commands.py | 1 + .../azext_quantum/tests/latest/test_quantum_workspace.py | 2 ++ src/quantum/setup.py | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 467c001995e..bf199d2900c 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -62,6 +62,7 @@ def one(result): ('Name', result.get('displayName')), ('Email', result.get('mail') or result.get('principalName')), ('Role', result.get('roleDefinitionName')), + ('Time Created', result.get('createdOn')), ('Principal Id', result.get('principalId')), ('Scope', result.get('scope')) ]) 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 fb1d210bd85..d63fee4248e 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -382,6 +382,7 @@ def test_transform_users(self): "principalName": "user@contoso.com", "displayName": "Contoso User", "mail": "user@contoso.com", + "createdOn": "2026-06-24T16:53:26.107178+00:00", "principalType": "User", "roleDefinitionName": "Quantum Workspace Data Contributor", "scope": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" @@ -389,6 +390,7 @@ def test_transform_users(self): assert rows[0]["Name"] == "Contoso User" assert rows[0]["Email"] == "user@contoso.com" assert rows[0]["Role"] == "Quantum Workspace Data Contributor" + assert rows[0]["Time Created"] == "2026-06-24T16:53:26.107178+00:00" assert rows[0]["Principal Id"] == "oid" # Email falls back to the principal name when Graph did not return a mail address. diff --git a/src/quantum/setup.py b/src/quantum/setup.py index ea438d3bff6..d4b5ecd82e2 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.0b21' +VERSION = '1.0.0b22' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From c900ca6ab24d86e2cbf5299b96ea0151341ed255 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 19 Aug 2026 10:23:18 -0700 Subject: [PATCH 12/15] Remove 'Role' field from user transformation and update related tests --- src/quantum/azext_quantum/commands.py | 4 +--- .../azext_quantum/tests/latest/test_quantum_workspace.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 8a21e04a586..0472267f951 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -61,10 +61,8 @@ def one(result): return OrderedDict([ ('Name', result.get('displayName')), ('Email', result.get('mail') or result.get('principalName')), - ('Role', result.get('roleDefinitionName')), ('Time Created', result.get('createdOn')), - ('Principal Id', result.get('principalId')), - ('Scope', result.get('scope')) + ('Principal Id', result.get('principalId')) ]) return [one(result) for result in results] 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 d63fee4248e..551b9731834 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -389,7 +389,6 @@ def test_transform_users(self): }]) assert rows[0]["Name"] == "Contoso User" assert rows[0]["Email"] == "user@contoso.com" - assert rows[0]["Role"] == "Quantum Workspace Data Contributor" assert rows[0]["Time Created"] == "2026-06-24T16:53:26.107178+00:00" assert rows[0]["Principal Id"] == "oid" From 939612f5f7e96250975c51fe5512bfa40f89982f Mon Sep 17 00:00:00 2001 From: Konstantin Date: Wed, 19 Aug 2026 17:51:45 -0700 Subject: [PATCH 13/15] Update user role handling in workspace commands --- src/quantum/azext_quantum/_help.py | 8 ++--- src/quantum/azext_quantum/commands.py | 1 + .../azext_quantum/operations/workspace.py | 8 ++++- .../tests/latest/test_quantum_workspace.py | 30 +++++++++++++++---- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 9eca899bfc1..5d9740148fa 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -368,10 +368,10 @@ type: command short-summary: List the users with access to an Azure Quantum workspace. long-summary: >- - Lists user principals (excluding groups and service principals) assigned the 'Quantum Workspace Data Contributor' - role for the given (or current) workspace. Each user's Name and Email are resolved from Microsoft Graph. By default - this includes access inherited from the parent resource group and subscription; pass '--include-inherited false' to - list only assignments scoped directly to the workspace. + Lists user principals (excluding groups and service principals) assigned the 'Quantum Workspace Owner' or + 'Quantum Workspace Data Contributor' role for the given (or current) workspace. Each user's Name and Email are + resolved from Microsoft Graph. By default this includes access inherited from the parent resource group and + subscription; pass '--include-inherited false' to list only assignments scoped directly to the workspace. examples: - name: List all users with access to a workspace. text: |- diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 0472267f951..72c6560c178 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -61,6 +61,7 @@ def one(result): return OrderedDict([ ('Name', result.get('displayName')), ('Email', result.get('mail') or result.get('principalName')), + ('Role', result.get('roleDefinitionName')), ('Time Created', result.get('createdOn')), ('Principal Id', result.get('principalId')) ]) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 134198d4f54..fc430cba3ad 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -48,6 +48,10 @@ # users when they are added to a workspace in the Azure Quantum portal. QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID = "c1410b24-3e69-4857-8f86-4d0a2e603250" +# Built-in "Quantum Workspace Owner" role. The Azure Quantum portal labels users +# holding this role as workspace administrators. +QUANTUM_WORKSPACE_OWNER_ROLE_ID = "30b3bcf2-670a-4bdc-8669-7e0ae0c0dfda" + C4A_TERMS_ACCEPTANCE_MESSAGE = "\nBy continuing you accept the Azure Quantum terms and conditions and privacy policy and agree that " \ "Microsoft can share your account details with the provider for their transactional purposes.\n\n" \ "https://privacy.microsoft.com/privacystatement\n" \ @@ -511,7 +515,9 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, include_inher info = WorkspaceInfo(cmd, resource_group_name, workspace_name) scope = _get_workspace_resource_id(info) - assignments = list_role_assignments(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=scope, include_inherited=include_inherited) + assignments = [] + for role_id in (QUANTUM_WORKSPACE_OWNER_ROLE_ID, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID): + assignments += list_role_assignments(cmd, role=role_id, scope=scope, include_inherited=include_inherited) users = [assignment for assignment in assignments if assignment.get("principalType") == "User"] _fill_user_display_names(cmd, users) return users 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 551b9731834..744a5e7ef55 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -17,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, list_users, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, 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, QUANTUM_WORKSPACE_OWNER_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -336,17 +336,35 @@ def test_list_users_scopes_to_workspace(self): assignments = [{"principalId": "oid", "principalName": "user@contoso.com", "principalType": "User", "roleDefinitionName": "Quantum Workspace Data Contributor"}] stubs = [{"id": "oid", "displayName": "Contoso User", "mail": "user@contoso.com", "userPrincipalName": "user@contoso.com"}] 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, \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", side_effect=[[], assignments]) as list_role_assignments, \ patch("azure.cli.command_modules.role.custom._graph_client_factory", return_value=object()), \ patch("azure.cli.command_modules.role.custom._get_object_stubs", return_value=stubs): 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, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=True) + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_OWNER_ROLE_ID, scope=expected_scope, include_inherited=True) + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=True) assert result[0]["displayName"] == "Contoso User" assert result[0]["mail"] == "user@contoso.com" + def test_list_users_includes_owner_and_contributor_roles(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + owner = [{"principalId": "o", "principalName": "owner@contoso.com", "principalType": "User", "roleDefinitionName": "Quantum Workspace Owner"}] + contributor = [{"principalId": "c", "principalName": "contrib@contoso.com", "principalType": "User", "roleDefinitionName": "Quantum Workspace Data Contributor"}] + stubs = [ + {"id": "o", "displayName": "Owner User", "mail": "owner@contoso.com", "userPrincipalName": "owner@contoso.com"}, + {"id": "c", "displayName": "Contrib User", "mail": "contrib@contoso.com", "userPrincipalName": "contrib@contoso.com"}, + ] + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", side_effect=[owner, contributor]), \ + patch("azure.cli.command_modules.role.custom._graph_client_factory", return_value=object()), \ + patch("azure.cli.command_modules.role.custom._get_object_stubs", return_value=stubs): + cmd = SimpleNamespace(cli_ctx=object()) + result = list_users(cmd, "rg", "ws") + + assert {user["roleDefinitionName"] for user in result} == {"Quantum Workspace Owner", "Quantum Workspace Data Contributor"} + def test_list_users_can_exclude_inherited(self): info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ @@ -355,7 +373,8 @@ def test_list_users_can_exclude_inherited(self): list_users(cmd, "rg", "ws", include_inherited=False) expected_scope = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" - list_role_assignments.assert_called_once_with(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=False) + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_OWNER_ROLE_ID, scope=expected_scope, include_inherited=False) + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, 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) @@ -366,7 +385,7 @@ def test_list_users_excludes_groups_and_service_principals(self): ] stubs = [{"id": "u", "displayName": "User One", "mail": "u@contoso.com", "userPrincipalName": "u@contoso.com"}] with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ - patch("azure.cli.command_modules.role.custom.list_role_assignments", return_value=assignments), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", side_effect=[assignments, []]), \ patch("azure.cli.command_modules.role.custom._graph_client_factory", return_value=object()), \ patch("azure.cli.command_modules.role.custom._get_object_stubs", return_value=stubs): cmd = SimpleNamespace(cli_ctx=object()) @@ -389,6 +408,7 @@ def test_transform_users(self): }]) assert rows[0]["Name"] == "Contoso User" assert rows[0]["Email"] == "user@contoso.com" + assert rows[0]["Role"] == "Quantum Workspace Data Contributor" assert rows[0]["Time Created"] == "2026-06-24T16:53:26.107178+00:00" assert rows[0]["Principal Id"] == "oid" From 97e89d525a252b0bedc873761e849a5cf4a0878f Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 20 Aug 2026 12:12:48 -0700 Subject: [PATCH 14/15] Remove 'Principal Id' assertion from user transformation tests --- src/quantum/azext_quantum/commands.py | 3 +-- .../azext_quantum/tests/latest/test_quantum_workspace.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 72c6560c178..8fba9d2d4c6 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -62,8 +62,7 @@ def one(result): ('Name', result.get('displayName')), ('Email', result.get('mail') or result.get('principalName')), ('Role', result.get('roleDefinitionName')), - ('Time Created', result.get('createdOn')), - ('Principal Id', result.get('principalId')) + ('Time Created', result.get('createdOn')) ]) return [one(result) for result in results] 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 744a5e7ef55..2407061624b 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -410,7 +410,6 @@ def test_transform_users(self): assert rows[0]["Email"] == "user@contoso.com" assert rows[0]["Role"] == "Quantum Workspace Data Contributor" assert rows[0]["Time Created"] == "2026-06-24T16:53:26.107178+00:00" - assert rows[0]["Principal Id"] == "oid" # Email falls back to the principal name when Graph did not return a mail address. fallback = transform_users([{"principalName": "fallback@contoso.com"}]) From 3231c18227171965ed16cb59f3568b13462da0d8 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 20 Aug 2026 15:15:10 -0700 Subject: [PATCH 15/15] Fix role assignment order in list_users function --- src/quantum/azext_quantum/operations/workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index fc430cba3ad..269026f900d 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -516,7 +516,7 @@ def list_users(cmd, resource_group_name=None, workspace_name=None, include_inher info = WorkspaceInfo(cmd, resource_group_name, workspace_name) scope = _get_workspace_resource_id(info) assignments = [] - for role_id in (QUANTUM_WORKSPACE_OWNER_ROLE_ID, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID): + for role_id in (QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID): assignments += list_role_assignments(cmd, role=role_id, scope=scope, include_inherited=include_inherited) users = [assignment for assignment in assignments if assignment.get("principalType") == "User"] _fill_user_display_names(cmd, users)