diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index ea09d57169b..f652bc49cf8 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +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. diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 831a608c524..5d9740148fa 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -364,6 +364,23 @@ 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 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: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace + - name: List only users assigned directly on the workspace (exclude inherited access). + text: |- + az quantum workspace user list -g MyResourceGroup -w MyWorkspace --include-inherited false +""" + 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 f37357cbcfc..084df56e7d7 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 @@ -75,6 +75,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 granted to the user; for 'delete', the role assignment to remove. Defaults 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'], 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) @@ -95,6 +96,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 40be7d86cba..8fba9d2d4c6 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -56,6 +56,17 @@ def transform_jobs(results): return [transform_job(job) for job in results] +def transform_users(results): + 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')) + ]) + return [one(result) for result in results] + + def transform_offerings(offerings): def one(offering): return OrderedDict([ @@ -139,6 +150,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..e7997d8cd45 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' @@ -38,12 +42,15 @@ POLLING_TIME_DURATION = 3 # Seconds MAX_RETRIES_ROLE_ASSIGNMENT = 20 +MAX_RETRIES_USER_LOOKUP = 3 MAX_POLLS_CREATE_WORKSPACE = 300 -# Built-in "Quantum Workspace Data Contributor" role. This is the role granted to -# users when they are added to a workspace in the Azure Quantum portal. +# Built-in "Quantum Workspace Data Contributor" role. QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID = "c1410b24-3e69-4857-8f86-4d0a2e603250" +# Built-in "Quantum Workspace Owner" role. +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" \ @@ -497,3 +504,53 @@ 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, include_inherited=True): + """ + 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) + assignments = [] + for role_id in (QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID): + # fill_principal_name=False avoids a per-call Microsoft Graph lookup that _fill_user_display_names already does in one batch. + assignments += list_role_assignments(cmd, role=role_id, scope=scope, include_inherited=include_inherited, fill_principal_name=False) + 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 + in a single batched lookup. This avoids a per-user Microsoft Graph lookup that would be slow and could hit throttling limits. + """ + principal_ids = {user["principalId"] for user in users if user.get("principalId")} + if not principal_ids: + return + + from azure.cli.command_modules.role import graph_client_factory + from azure.cli.command_modules.role.custom import _get_object_stubs + + directory_objects = {} + for attempt in range(MAX_RETRIES_USER_LOOKUP): + try: + graph_client = graph_client_factory(cmd.cli_ctx) + directory_objects = {obj.get("id"): obj for obj in _get_object_stubs(graph_client, principal_ids)} + if principal_ids.issubset(directory_objects): + break + except Exception: # pylint: disable=broad-except + directory_objects = {} + + if attempt < MAX_RETRIES_USER_LOOKUP - 1: + time.sleep(1) + else: + raise AzureInternalError("Could not resolve user names and email addresses from Microsoft Graph. Please try again later.") + + for user in users: + obj = directory_objects.get(user.get("principalId"), {}) + user["displayName"] = obj.get("displayName") or obj.get("userPrincipalName") + user["mail"] = obj.get("mail") or obj.get("userPrincipalName") 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..d7357decde1 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -7,15 +7,17 @@ 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) -from azure.cli.core.azclierror import RequiredArgumentMissingError, ResourceNotFoundError, InvalidArgumentValueError +from azure.cli.core.azclierror import RequiredArgumentMissingError, ResourceNotFoundError, InvalidArgumentValueError, AzureInternalError from .utils import get_test_resource_group, get_test_workspace, get_test_workspace_location, get_test_workspace_storage, get_test_workspace_storage_grs, get_test_workspace_random_name, get_test_workspace_random_long_name, get_test_capabilities, get_test_workspace_provider_sku_list, get_test_workspace_v2_provider_sku_list, all_providers_are_in_capabilities, issue_cmd_with_param_missing 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, 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__), '..')) @@ -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} --include-inherited false -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') @@ -416,3 +423,137 @@ class TestWorkspaceInfo(object): resource_id = _get_workspace_resource_id(TestWorkspaceInfo()) assert resource_id == "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroup/providers/Microsoft.Quantum/Workspaces/MyWorkspace" + + +class QuantumWorkspaceUserListTest(unittest.TestCase): + def test_list_users_scopes_to_workspace(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + 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", side_effect=[[], assignments]) as list_role_assignments, \ + patch("azure.cli.command_modules.role.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_any_call(cmd, role=QUANTUM_WORKSPACE_OWNER_ROLE_ID, scope=expected_scope, include_inherited=True, fill_principal_name=False) + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=True, fill_principal_name=False) + self.assertEqual(result[0]["displayName"], "Contoso User") + self.assertEqual(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.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") + + self.assertEqual({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), \ + 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=False) + + expected_scope = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Quantum/Workspaces/ws" + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_OWNER_ROLE_ID, scope=expected_scope, include_inherited=False, fill_principal_name=False) + list_role_assignments.assert_any_call(cmd, role=QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, scope=expected_scope, include_inherited=False, fill_principal_name=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"}, + ] + 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", side_effect=[assignments, []]), \ + patch("azure.cli.command_modules.role.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") + + self.assertEqual([user["principalId"] for user in result], ["u"]) + self.assertEqual(result[0]["displayName"], "User One") + + def test_list_users_falls_back_to_upn_when_display_name_missing(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + assignments = [{"principalId": "u", "principalType": "User", "roleDefinitionName": "Quantum Workspace Data Contributor"}] + # Graph resolves the principal but returns no displayName/mail (only the UPN). + stubs = [{"id": "u", "userPrincipalName": "user@contoso.com"}] + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", side_effect=[assignments, []]), \ + patch("azure.cli.command_modules.role.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") + + # Name and Email fall back to the UPN from Graph. + self.assertEqual(result[0]["displayName"], "user@contoso.com") + self.assertEqual(result[0]["mail"], "user@contoso.com") + + def test_list_users_retries_graph_error(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + assignments = [{"principalId": "u", "principalType": "User"}] + stubs = [{"id": "u", "displayName": "User One", "mail": "u@contoso.com"}] + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", side_effect=[assignments, []]), \ + patch("azure.cli.command_modules.role.graph_client_factory", return_value=object()), \ + patch("azure.cli.command_modules.role.custom._get_object_stubs", side_effect=[Exception("temporary"), stubs]) as get_object_stubs, \ + patch("azext_quantum.operations.workspace.time.sleep") as sleep: + cmd = SimpleNamespace(cli_ctx=object()) + result = list_users(cmd, "rg", "ws") + + self.assertEqual(result[0]["displayName"], "User One") + self.assertEqual(get_object_stubs.call_count, 2) + sleep.assert_called_once_with(1) + + def test_list_users_raises_when_graph_cannot_resolve_users(self): + info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) + assignments = [{"principalId": "u", "principalType": "User"}] + with patch("azext_quantum.operations.workspace.WorkspaceInfo", return_value=info), \ + patch("azure.cli.command_modules.role.custom.list_role_assignments", side_effect=[assignments, []]), \ + patch("azure.cli.command_modules.role.graph_client_factory", return_value=object()), \ + patch("azure.cli.command_modules.role.custom._get_object_stubs", return_value=[]) as get_object_stubs, \ + patch("azext_quantum.operations.workspace.time.sleep") as sleep: + cmd = SimpleNamespace(cli_ctx=object()) + with self.assertRaisesRegex(AzureInternalError, "Please try again later"): + list_users(cmd, "rg", "ws") + + self.assertEqual(get_object_stubs.call_count, 3) + self.assertEqual(sleep.call_count, 2) + + 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", + "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" + }]) + self.assertEqual(rows[0]["Name"], "Contoso User") + self.assertEqual(rows[0]["Email"], "user@contoso.com") + self.assertEqual(rows[0]["Role"], "Quantum Workspace Data Contributor") + self.assertEqual(rows[0]["Time Created"], "2026-06-24T16:53:26.107178+00:00") + + # Email falls back to the principal name when Graph did not return a mail address. + fallback = transform_users([{"principalName": "fallback@contoso.com"}]) + self.assertEqual(fallback[0]["Email"], "fallback@contoso.com") + self.assertIsNone(fallback[0]["Name"]) 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