Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ad32f7c
Add `az quantum workspace user list` command to manage user access
Aug 12, 2026
0d61226
Add support for `--include-inherited` flag in `az quantum workspace u…
Aug 13, 2026
874c812
update 'role' implementation
Aug 14, 2026
f8d6d7a
exclude groups and service principals
Aug 14, 2026
06fb090
Potential fix for pull request finding
kaverkiev Aug 18, 2026
aca2e71
Add action flag to `--include-inherited` parameter
Aug 18, 2026
3834ec1
Refactoring. Removed role, assignee,assignee_object_id params
Aug 18, 2026
b0ae63d
Remove comment
Aug 18, 2026
661bf7e
add name and email
Aug 19, 2026
470196d
Update release history for version 1.0.0b22 and add new command details
Aug 19, 2026
d8df847
Merge branch 'main' into v-kaverkiev/workspace-user-list
Aug 19, 2026
e2aa1c8
Merge branch 'main' into v-kaverkiev/workspace-user-list
kaverkiev Aug 19, 2026
0b931b5
Add 'Time Created' field to user transformation and update version to…
Aug 19, 2026
811044a
Merge branch 'v-kaverkiev/workspace-user-list' of https://github.com/…
Aug 19, 2026
c900ca6
Remove 'Role' field from user transformation and update related tests
Aug 19, 2026
939612f
Update user role handling in workspace commands
Aug 20, 2026
97e89d5
Remove 'Principal Id' assertion from user transformation tests
Aug 20, 2026
3231c18
Fix role assignment order in list_users function
Aug 20, 2026
f58d004
Refactor graph client import
Aug 21, 2026
b38065f
Fallback to principal name for displayName
Aug 21, 2026
5038027
moved test in separate class
Aug 21, 2026
4f1f6a5
Update list_users to batch principal name resolution and adjust tests…
Aug 21, 2026
3f8cd48
update comments
Aug 21, 2026
5c29c68
Update user list command to exclude inherited users
Aug 21, 2026
b1f23f8
Update role parameter help text
Aug 21, 2026
3bc00cc
Add retry logic for user lookup and tests
Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/quantum/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/quantum/azext_quantum/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/quantum/azext_quantum/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/quantum/azext_quantum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change that to "Time Added"?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for this rename? It will break consistency with OS shell. Currently, we mirroring OS Shell structure, just as we discussed.

])
return [one(result) for result in results]


def transform_offerings(offerings):
def one(offering):
return OrderedDict([
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 59 additions & 2 deletions src/quantum/azext_quantum/operations/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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" \
Expand Down Expand Up @@ -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


Comment on lines +511 to +525

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fill_principal_name=False removed the duplicate Graph calls, but workspace.py:515-521 still loops and calls list_role_assignments twice, and fill_role_definition_name still defaults to True. That enumerates every role definition visible at the workspace scope (hundreds of built-ins) — twice. azure-cli's own help calls this out: "Fill roleDefinitionName property in addition to roleDefinitionId. This operation is expensive."

A single call filtered client-side halves the ARM traffic, consider to changing to:

def list_users(cmd, resource_group_name=None, workspace_name=None, include_inherited=True):
    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 = list_role_assignments(cmd, scope=scope, include_inherited=include_inherited)
    quantum_roles = (QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID)
    users = [a for a in assignments
             if a.get("principalType") == "User"
             and a.get("roleDefinitionId", "").rsplit("/", 1)[-1].lower() in quantum_roles]
    _fill_user_display_names(cmd, users)
    return users
    ```

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This change broke order (Data contributor first) that you are requested before. Single call returns ARM's order, so we'd need an explicit client-side sort to keep Contributor-first. Extra logic. Please confirm.
  2. You dropped fill_principal_name=False. Without it you will re-introduce the Graph redundancy that I just removed
  3. Potentially unfiltered call returns all roles at scope before filtering. With a busy subscription scope the response is might be huge.

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:
Comment thread
kaverkiev marked this conversation as resolved.
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")
Loading
Loading