diff --git a/src/azure-cli/azure/cli/command_modules/role/_help.py b/src/azure-cli/azure/cli/command_modules/role/_help.py index 2163325534a..66e31761a9f 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_help.py +++ b/src/azure-cli/azure/cli/command_modules/role/_help.py @@ -793,11 +793,19 @@ type: command short-summary: List role assignments. long-summary: >- - By default, only assignments scoped to subscription will be displayed. - To view assignments scoped by resource or group, use `--all`. + By default, the scope is the current subscription. Specifying the scope with `--scope` is recommended. + + + By default, only role assignments exactly at the scope are included, not including role assignments at + parent scopes or sub-scopes. For example, when `--scope` is a subscription, role assignments at management + groups or resource groups are not included. + To include role assignments at parent scopes, use `--include-inherited`. + To include role assignments at parent scopes and sub-scopes, use `--at-scope false`. examples: - - name: List role assignments at the subscription scope. + - name: List role assignments exactly at the subscription scope. text: az role assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000 + - name: List role assignments at the subscription scope, including parent scopes and sub-scopes. + text: az role assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000 --at-scope false - name: List role assignments at the subscription scope, without filling roleDefinitionName property. text: az role assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000 --fill-role-definition-name false - name: List role assignments with "Reader" role at the subscription scope. diff --git a/src/azure-cli/azure/cli/command_modules/role/_params.py b/src/azure-cli/azure/cli/command_modules/role/_params.py index 4e1249d42ad..994d0d6c1bc 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_params.py +++ b/src/azure-cli/azure/cli/command_modules/role/_params.py @@ -330,8 +330,12 @@ def load_arguments(self, _): with self.argument_context('role assignment') as c: c.argument('role', help='role name or id', completer=get_role_definition_name_completion_list) - c.argument('show_all', options_list=['--all'], action='store_true', help='show all assignments under the current subscription') - c.argument('include_inherited', action='store_true', help='include assignments applied on parent scopes') + c.argument('show_all', options_list=['--all'], action='store_true', + deprecate_info=c.deprecate(target='--all'), + help="Show all assignments under the current subscription. This argument is deprecated. " + "Use '--at-scope false' instead.") + c.argument('include_inherited', action='store_true', + help='Include role assignments at parent scopes. This argument only takes effect when --at-scope is true.') c.argument('can_delegate', action='store_true', help='when set, the assignee will be able to create further role assignments to the same role') c.argument('assignee', help='represent a user, group, or service principal. supported format: object id, user sign-in name, or service principal name') c.argument('assignee_object_id', @@ -345,6 +349,11 @@ def load_arguments(self, _): c.argument('condition_version', is_preview=True, min_api='2020-04-01-preview', help='Version of the condition syntax. If --condition is specified without --condition-version, default to 2.0.') c.argument('assignment_name', name_arg_type, help='A GUID for the role assignment. It must be unique and different for each role assignment. If omitted, a new GUID is generated.') + c.argument('at_scope', arg_type=get_three_state_flag(), + help='If true, only assignments exactly at the scope are included, not including role assignments ' + 'at parent scopes or sub-scopes. Specify --include-inherited to include assignments at ' + 'parent scopes. ' + 'If false, assignments on parent scopes and sub-scopes are included.') with self.argument_context('role assignment list') as c: c.argument('fill_principal_name', arg_type=get_three_state_flag(), diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index ecd65306fe2..321db2ebe34 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -227,7 +227,8 @@ def _create_role_assignment(cli_ctx, role, assignee, resource_group_name=None, s def list_role_assignments(cmd, # pylint: disable=too-many-locals, too-many-branches assignee=None, assignee_object_id=None, role=None, - resource_group_name=None, scope=None, + scope=None, at_scope=True, + resource_group_name=None, include_inherited=False, show_all=False, include_groups=False, fill_role_definition_name=True, fill_principal_name=True): @@ -251,7 +252,8 @@ def list_role_assignments(cmd, # pylint: disable=too-many-locals, too-many-bran assignee_object_id = _resolve_object_id(cmd.cli_ctx, assignee, fallback_to_object_id=True) assignments = _search_role_assignments(assignments_client, definitions_client, scope, assignee_object_id, role, - include_inherited, include_groups) + include_inherited, include_groups, + at_scope=at_scope) results = todict(assignments) if assignments else [] @@ -499,7 +501,7 @@ def delete_role_assignments(cmd, ids=None, assignee_object_id = _resolve_object_id(cmd.cli_ctx, assignee, fallback_to_object_id=True) assignments = _search_role_assignments(assignments_client, definitions_client, scope, assignee_object_id, role, include_inherited, - include_groups=False) + include_groups=False, at_scope=True) if assignments: for a in assignments: @@ -509,14 +511,20 @@ def delete_role_assignments(cmd, ids=None, def _search_role_assignments(assignments_client, definitions_client, - scope, assignee_object_id, role, include_inherited, include_groups): + scope, assignee_object_id, role, include_inherited, include_groups, + at_scope=None): # https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-list-rest # "atScope()" and "principalId eq '{value}'" query cannot be used together (API limitation). # always use "scope" if provided, so we can get assignments beyond subscription e.g. management groups if scope: - f = 'atScope()' # atScope() excludes role assignments at subscopes + filters = [] + if at_scope: + filters.append('atScope()') # atScope() excludes role assignments at subscopes + if assignee_object_id and not include_groups and not at_scope: + filters.append("principalId eq '{}'".format(assignee_object_id)) if assignee_object_id and include_groups: - f = f + " and assignedTo('{}')".format(assignee_object_id) + filters.append("assignedTo('{}')".format(assignee_object_id)) + f = ' and '.join(filters) if filters else None assignments = list(assignments_client.list_for_scope(scope=scope, filter=f)) elif assignee_object_id: if include_groups: @@ -529,8 +537,10 @@ def _search_role_assignments(assignments_client, definitions_client, if assignments: assignments = [ra for ra in assignments if ( - # If no scope, list all assignments + # If no scope (--all), list all assignments not scope or + # If --at-scope false, list all assignments + not at_scope or # If scope is provided with include_inherited, list assignments at and above the scope. # Note that assignments below the scope are already excluded by atScope() include_inherited or diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/recordings/test_role_assignment_at_scope.yaml b/src/azure-cli/azure/cli/command_modules/role/tests/latest/recordings/test_role_assignment_at_scope.yaml new file mode 100644 index 00000000000..7e216a63ee1 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/recordings/test_role_assignment_at_scope.yaml @@ -0,0 +1,209 @@ +interactions: +- request: + body: '{"location": "westus"}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - identity create + Connection: + - keep-alive + Content-Length: + - '22' + Content-Type: + - application/json + ParameterSetName: + - -g -n --location + User-Agent: + - AZURECLI/2.71.0 azsdk-python-core/1.31.0 Python/3.12.10 (Windows-11-10.0.26100-SP0) + method: PUT + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001/providers/Microsoft.ManagedIdentity/userAssignedIdentities/clitest000002?api-version=2023-01-31 + response: + body: + string: '{"location":"westus","tags":{},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/cli_role_assign000001/providers/Microsoft.ManagedIdentity/userAssignedIdentities/clitest000002","name":"clitest000002","type":"Microsoft.ManagedIdentity/userAssignedIdentities","properties":{"tenantId":"54826b22-38d6-4fb2-bad9-b7b93a3e9c5a","principalId":"88c53162-584e-4c98-984b-62149c2e9bca","clientId":"8cc2fab2-7cc6-4863-aae8-9ee986d5a5ac"}}' + headers: + cache-control: + - no-cache + content-length: + - '449' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 14 Apr 2025 08:54:23 GMT + expires: + - '-1' + location: + - /subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/cli_role_assign000001/providers/Microsoft.ManagedIdentity/userAssignedIdentities/clitest000002 + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=54826b22-38d6-4fb2-bad9-b7b93a3e9c5a,objectId=0d504196-1423-4569-9a6e-15149656f0ee/japanwest/90ea6275-c25c-4d73-ba0a-55e6fbe522bb + x-ms-ratelimit-remaining-subscription-global-writes: + - '2999' + x-ms-ratelimit-remaining-subscription-writes: + - '199' + x-msedge-ref: + - 'Ref A: 4359A8C8A1DE4A538CC6C54BFB82E1A0 Ref B: TYO201100114011 Ref C: 2025-04-14T08:54:21Z' + status: + code: 201 + message: Created +- request: + body: '{"properties": {"roleDefinitionId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", + "principalId": "88c53162-584e-4c98-984b-62149c2e9bca", "principalType": "ServicePrincipal"}}' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - role assignment create + Connection: + - keep-alive + Content-Length: + - '270' + Content-Type: + - application/json + ParameterSetName: + - --assignee-object-id --assignee-principal-type --role --scope + User-Agent: + - AZURECLI/2.71.0 azsdk-python-core/1.31.0 Python/3.12.10 (Windows-11-10.0.26100-SP0) + method: PUT + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001/providers/Microsoft.Authorization/roleAssignments/88888888-0000-0000-0000-000000000001?api-version=2022-04-01 + response: + body: + string: '{"properties":{"roleDefinitionId":"/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7","principalId":"88c53162-584e-4c98-984b-62149c2e9bca","principalType":"ServicePrincipal","scope":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001","condition":null,"conditionVersion":null,"createdOn":"2025-04-14T08:54:25.8958593Z","updatedOn":"2025-04-14T08:54:26.7153899Z","createdBy":null,"updatedBy":"0d504196-1423-4569-9a6e-15149656f0ee","delegatedManagedIdentityResourceId":null,"description":null},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001/providers/Microsoft.Authorization/roleAssignments/88888888-0000-0000-0000-000000000001","type":"Microsoft.Authorization/roleAssignments","name":"88888888-0000-0000-0000-000000000001"}' + headers: + cache-control: + - no-cache + content-length: + - '897' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 14 Apr 2025 08:54:30 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=54826b22-38d6-4fb2-bad9-b7b93a3e9c5a,objectId=0d504196-1423-4569-9a6e-15149656f0ee/japaneast/5aef1660-efc8-4259-ada4-ff3377587bdb + x-ms-ratelimit-remaining-subscription-global-writes: + - '2999' + x-ms-ratelimit-remaining-subscription-writes: + - '199' + x-msedge-ref: + - 'Ref A: 06485B5DC8264B9C917E7D0942EDE412 Ref B: TYO201151002025 Ref C: 2025-04-14T08:54:25Z' + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - role assignment list + Connection: + - keep-alive + ParameterSetName: + - --scope --at-scope --assignee-object-id --fill-role-definition-name --fill-principal-name + User-Agent: + - AZURECLI/2.71.0 azsdk-python-core/1.31.0 Python/3.12.10 (Windows-11-10.0.26100-SP0) + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleAssignments?$filter=principalId%20eq%20'88c53162-584e-4c98-984b-62149c2e9bca'&api-version=2022-04-01 + response: + body: + string: '{"value":[{"properties":{"roleDefinitionId":"/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7","principalId":"88c53162-584e-4c98-984b-62149c2e9bca","principalType":"ServicePrincipal","scope":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001","condition":null,"conditionVersion":null,"createdOn":"2025-04-14T08:54:26.7153899Z","updatedOn":"2025-04-14T08:54:26.7153899Z","createdBy":"0d504196-1423-4569-9a6e-15149656f0ee","updatedBy":"0d504196-1423-4569-9a6e-15149656f0ee","delegatedManagedIdentityResourceId":null,"description":null},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001/providers/Microsoft.Authorization/roleAssignments/88888888-0000-0000-0000-000000000001","type":"Microsoft.Authorization/roleAssignments","name":"88888888-0000-0000-0000-000000000001"}]}' + headers: + cache-control: + - no-cache + content-length: + - '943' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 14 Apr 2025 08:54:31 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=54826b22-38d6-4fb2-bad9-b7b93a3e9c5a,objectId=0d504196-1423-4569-9a6e-15149656f0ee/japaneast/4f3785c6-7da5-4a37-9e9a-37b877a366e0 + x-ms-ratelimit-remaining-subscription-global-reads: + - '3749' + x-msedge-ref: + - 'Ref A: F01D790831084190BDA2FD89661CF517 Ref B: TYO201151001054 Ref C: 2025-04-14T08:54:31Z' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + CommandName: + - role assignment list + Connection: + - keep-alive + ParameterSetName: + - --scope --at-scope --assignee-object-id --fill-role-definition-name --fill-principal-name + User-Agent: + - AZURECLI/2.71.0 azsdk-python-core/1.31.0 Python/3.12.10 (Windows-11-10.0.26100-SP0) + method: GET + uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001/providers/Microsoft.Authorization/roleAssignments?$filter=principalId%20eq%20'88c53162-584e-4c98-984b-62149c2e9bca'&api-version=2022-04-01 + response: + body: + string: '{"value":[{"properties":{"roleDefinitionId":"/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7","principalId":"88c53162-584e-4c98-984b-62149c2e9bca","principalType":"ServicePrincipal","scope":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001","condition":null,"conditionVersion":null,"createdOn":"2025-04-14T08:54:26.7153899Z","updatedOn":"2025-04-14T08:54:26.7153899Z","createdBy":"0d504196-1423-4569-9a6e-15149656f0ee","updatedBy":"0d504196-1423-4569-9a6e-15149656f0ee","delegatedManagedIdentityResourceId":null,"description":null},"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_role_assign000001/providers/Microsoft.Authorization/roleAssignments/88888888-0000-0000-0000-000000000001","type":"Microsoft.Authorization/roleAssignments","name":"88888888-0000-0000-0000-000000000001"}]}' + headers: + cache-control: + - no-cache + content-length: + - '943' + content-type: + - application/json; charset=utf-8 + date: + - Mon, 14 Apr 2025 08:54:31 GMT + expires: + - '-1' + pragma: + - no-cache + strict-transport-security: + - max-age=31536000; includeSubDomains + x-cache: + - CONFIG_NOCACHE + x-content-type-options: + - nosniff + x-ms-operation-identifier: + - tenantId=54826b22-38d6-4fb2-bad9-b7b93a3e9c5a,objectId=0d504196-1423-4569-9a6e-15149656f0ee/japaneast/b891f240-fe35-43fb-a8ef-cd9597db7f7e + x-ms-ratelimit-remaining-subscription-global-reads: + - '3749' + x-msedge-ref: + - 'Ref A: 0DD953D8592F4F9796E6678A325D0EAB Ref B: TYO201151002025 Ref C: 2025-04-14T08:54:32Z' + status: + code: 200 + message: OK +version: 1 diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py index 165fffd8998..44453296e41 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_role.py @@ -757,6 +757,40 @@ def test_role_assignment_no_graph(self, resource_group): self.cmd('role assignment list --all --assignee-object-id {uami_object_id}', checks=self.check("length([])", 0)) + @ResourceGroupPreparer(name_prefix='cli_role_assign') + def test_role_assignment_at_scope(self, resource_group): + with mock.patch('azure.cli.command_modules.role.custom._gen_guid', side_effect=self.create_guid): + self.kwargs.update({ + 'uami': self.create_random_name('clitest', 15), # user-assigned managed identity + # https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles + 'role_reader_guid': 'acdd72a7-3385-48ef-bd42-f606fba81ae7' + }) + self._prepare_scope_kwargs() + + uami = self.cmd('identity create -g {rg} -n {uami} --location westus').get_output_in_json() + self.kwargs['uami_object_id'] = uami['principalId'] + + self.cmd('role assignment create ' + '--assignee-object-id {uami_object_id} --assignee-principal-type ServicePrincipal ' + '--role {role_reader_guid} --scope {rg_id}') + # Verify atScope() is not bound to scope, + # and when atScope() is not specified, scope can be used with `principalId eq '{}'` filter. + # At subscription scope + self.cmd('role assignment list --scope {sub_id} --at-scope false ' + '--assignee-object-id {uami_object_id} ' + '--fill-role-definition-name false --fill-principal-name false', + checks=[ + self.check("length([])", 1), + self.check("[0].scope", '{rg_id}'), + ]) + # At resource group scope + self.cmd('role assignment list --scope {rg_id} --at-scope false ' + '--assignee-object-id {uami_object_id} ' + '--fill-role-definition-name false --fill-principal-name false', + checks=[ + self.check("length([])", 1), + self.check("[0].scope", '{rg_id}'), + ]) class RoleAssignmentWithConfigScenarioTest(RoleScenarioTestBase):