Skip to content

Commit 92f2c3d

Browse files
jruttleJonathan RuttleCopilot
authored
[Role] Feature: Add az role deny-assignment create/delete commands (#33109)
Co-authored-by: Jonathan Ruttle <jruttle@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 15f87e2 commit 92f2c3d

17 files changed

Lines changed: 793 additions & 28 deletions

File tree

src/azure-cli-core/azure/cli/core/commands/arm.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,11 @@ def create_role_assignment(cli_ctx, principal_id, identity_role=None, identity_s
774774
import time
775775
from azure.core.exceptions import HttpResponseError
776776

777+
# Coerce AAZSimpleValue/model values to plain strings for SDK 5.0.0b2 compatibility
778+
principal_id = str(principal_id) if principal_id else principal_id
779+
identity_role = str(identity_role) if identity_role else identity_role
780+
identity_scope = str(identity_scope) if identity_scope else identity_scope
781+
777782
identity_role_id = resolve_role_id(cli_ctx, identity_role, identity_scope)
778783
assignments_client = get_mgmt_service_client(cli_ctx, ResourceType.MGMT_AUTHORIZATION).role_assignments
779784
RoleAssignmentCreateParameters = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION,
@@ -818,7 +823,7 @@ def resolve_role_id(cli_ctx, role, scope):
818823
except ValueError:
819824
pass
820825
if not role_id: # retrieve role id
821-
role_defs = list(client.list(scope, "roleName eq '{}'".format(role)))
826+
role_defs = list(client.list(scope, filter="roleName eq '{}'".format(role)))
822827
if not role_defs:
823828
raise CLIError("Role '{}' doesn't exist.".format(role))
824829
if len(role_defs) > 1:

src/azure-cli/azure/cli/command_modules/acs/_roleassignments.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def resolve_role_id(role, scope, definitions_client):
3939
pass
4040
if not role_id: # retrieve role id
4141
role_defs = list(definitions_client.list(
42-
scope, "roleName eq '{}'".format(role)))
42+
scope, filter="roleName eq '{}'".format(role)))
4343
if len(role_defs) == 0:
4444
raise AzCLIError("Role '{}' doesn't exist.".format(role))
4545
if len(role_defs) > 1:

src/azure-cli/azure/cli/command_modules/ams/operations/sp.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from dateutil.relativedelta import relativedelta
1212

13-
from knack.util import CLIError, todict
13+
from knack.util import CLIError
1414
from knack.log import get_logger
1515
from msrest.serialization import TZ_UTC
1616
from azure.core.exceptions import HttpResponseError
@@ -137,6 +137,35 @@ def _get_displayable_name(graph_object):
137137
return graph_object.get('name') or ''
138138

139139

140+
def _role_assignment_to_dict(assignment):
141+
'''Project a role assignment to the legacy flat camelCase dict.
142+
143+
azure-mgmt-authorization 5.x (TypeSpec-generated) wraps the role assignment's
144+
domain attributes in a nested ``properties`` envelope, and ``knack.util.todict``
145+
does not walk these MutableMapping models. The SDK still exposes the values as
146+
snake_case attributes, so read them directly to keep the flat shape downstream
147+
code expects.
148+
'''
149+
if assignment is None:
150+
return None
151+
152+
def _coerce(value):
153+
from enum import Enum
154+
if isinstance(value, Enum):
155+
return value.value
156+
return value
157+
158+
result = {}
159+
for attr in ('id', 'name', 'type'):
160+
result[attr] = _coerce(getattr(assignment, attr, None))
161+
for snake_attr, camel_key in (('scope', 'scope'),
162+
('role_definition_id', 'roleDefinitionId'),
163+
('principal_id', 'principalId'),
164+
('principal_type', 'principalType')):
165+
result[camel_key] = _coerce(getattr(assignment, snake_attr, None))
166+
return result
167+
168+
140169
def list_role_assignments(cmd, assignee_object_id, scope=None):
141170
'''
142171
:param include_groups: include extra assignments to the groups of which the user is a
@@ -151,7 +180,7 @@ def list_role_assignments(cmd, assignee_object_id, scope=None):
151180

152181
subscription_id = get_subscription_id(cmd.cli_ctx)
153182

154-
results = todict(assignments) if assignments else []
183+
results = [_role_assignment_to_dict(a) for a in assignments] if assignments else []
155184

156185
if not results:
157186
return []
@@ -214,7 +243,7 @@ def _resolve_role_id(cli_ctx, role, scope, definitions_client):
214243
role_id = '/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/{}'.format(
215244
subscription_id, role)
216245
if not role_id: # retrieve role id
217-
role_defs = list(definitions_client.list(scope, "roleName eq '{}'".format(role)))
246+
role_defs = list(definitions_client.list(scope, filter="roleName eq '{}'".format(role)))
218247

219248
if not role_defs:
220249
raise CLIError("Role '{}' doesn't exist.".format(role))

src/azure-cli/azure/cli/command_modules/aro/tests/latest/recordings/test_aro_public_cluster.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,7 @@ interactions:
12801280
User-Agent:
12811281
- AZURECLI/2.65.0 azsdk-python-core/1.31.0 Python/3.11.9 (macOS-14.7-x86_64-i386-64bit)
12821282
method: GET
1283-
uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/cli_test_aro000001/providers/Microsoft.Network/virtualNetworks/dev-vnet/providers/Microsoft.Authorization/permissions?api-version=2022-05-01-preview
1283+
uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_test_aro000001/providers/Microsoft.Network//virtualNetworks/dev-vnet/providers/Microsoft.Authorization/permissions?api-version=2022-05-01-preview
12841284
response:
12851285
body:
12861286
string: '{"value":[{"actions":["*/read","Microsoft.Authorization/*","Microsoft.Support/*"],"notActions":[]},{"actions":["*"],"notActions":["Microsoft.Authorization/*/Delete","Microsoft.Authorization/*/Write","Microsoft.Authorization/elevateAccess/Action","Microsoft.Blueprint/blueprintAssignments/write","Microsoft.Blueprint/blueprintAssignments/delete","Microsoft.Compute/galleries/share/action","Microsoft.Purview/consents/write","Microsoft.Purview/consents/delete","Microsoft.Resources/deploymentStacks/manageDenySetting/action"]}]}'
@@ -2117,7 +2117,7 @@ interactions:
21172117
User-Agent:
21182118
- AZURECLI/2.65.0 azsdk-python-core/1.31.0 Python/3.11.9 (macOS-14.7-x86_64-i386-64bit)
21192119
method: GET
2120-
uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/cli_test_aro000001/providers/Microsoft.Network/virtualNetworks/dev-vnet/providers/Microsoft.Authorization/permissions?api-version=2022-05-01-preview
2120+
uri: https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/cli_test_aro000001/providers/Microsoft.Network//virtualNetworks/dev-vnet/providers/Microsoft.Authorization/permissions?api-version=2022-05-01-preview
21212121
response:
21222122
body:
21232123
string: '{"value":[{"actions":["*/read","Microsoft.Authorization/*","Microsoft.Support/*"],"notActions":[]},{"actions":["*"],"notActions":["Microsoft.Authorization/*/Delete","Microsoft.Authorization/*/Write","Microsoft.Authorization/elevateAccess/Action","Microsoft.Blueprint/blueprintAssignments/write","Microsoft.Blueprint/blueprintAssignments/delete","Microsoft.Compute/galleries/share/action","Microsoft.Purview/consents/write","Microsoft.Purview/consents/delete","Microsoft.Resources/deploymentStacks/manageDenySetting/action"]}]}'

src/azure-cli/azure/cli/command_modules/containerapp/_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def _create_role_assignment(cli_ctx, role, assignee, scope=None):
142142
definitions_client = auth_client.role_definitions
143143

144144
assignment_name = uuid.uuid4()
145-
role_defs = list(definitions_client.list(scope, "roleName eq '{}'".format(role)))
145+
role_defs = list(definitions_client.list(scope, filter="roleName eq '{}'".format(role)))
146146
role_id = role_defs[0].id
147147

148148
api_version = supported_api_version(cli_ctx, resource_type=ResourceType.MGMT_AUTHORIZATION, max_api='2015-07-01')

src/azure-cli/azure/cli/command_modules/resource/policy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,10 @@ def CreateRoleAssignment(ctx, cli_ctx, assignment):
137137
if has_value(ctx.args.identity_scope):
138138
identity_role = None
139139
if has_value(ctx.args.role):
140-
identity_role = ctx.args.role
140+
identity_role = ctx.args.role._data
141141
assign_identity(
142142
cli_ctx, lambda: assignment, lambda resource: assignment,
143-
identity_role._data, ctx.args.identity_scope._data)
143+
identity_role, ctx.args.identity_scope._data)
144144

145145
# Implement default identity type behavior for policy assignment create
146146
@staticmethod

src/azure-cli/azure/cli/command_modules/role/_help.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,96 @@
813813
short-summary: List changelogs for role assignments.
814814
"""
815815

816+
helps['role deny-assignment'] = """
817+
type: group
818+
short-summary: Manage deny assignments.
819+
long-summary: >-
820+
Deny assignments block users from performing specific Azure resource actions even if a role assignment
821+
grants them access. User-assigned deny assignments can be created to deny write, delete, and action
822+
operations at a given scope while excluding specific principals.
823+
"""
824+
825+
helps['role deny-assignment list'] = """
826+
type: command
827+
short-summary: List deny assignments.
828+
examples:
829+
- name: List deny assignments at the subscription scope.
830+
text: az role deny-assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000
831+
- name: List all deny assignments in the current subscription.
832+
text: az role deny-assignment list
833+
- name: List deny assignments at a resource group scope.
834+
text: az role deny-assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
835+
"""
836+
837+
helps['role deny-assignment show'] = """
838+
type: command
839+
short-summary: Get a deny assignment.
840+
examples:
841+
- name: Show a deny assignment by its fully qualified ID.
842+
text: >-
843+
az role deny-assignment show
844+
--id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001
845+
- name: Show a deny assignment by name and scope.
846+
text: >-
847+
az role deny-assignment show
848+
--name 00000000-0000-0000-0000-000000000001
849+
--scope /subscriptions/00000000-0000-0000-0000-000000000000
850+
"""
851+
852+
helps['role deny-assignment create'] = """
853+
type: command
854+
short-summary: Create a user-assigned deny assignment.
855+
long-summary: >-
856+
Creates a deny assignment that blocks specific actions at the given scope. Two modes are supported:
857+
(1) Everyone mode (default) — denies actions for all principals, requiring at least one excluded principal;
858+
(2) Per-principal mode — denies actions for a specific User or ServicePrincipal specified via --principal-object-id.
859+
DataActions are not supported, DoNotApplyToChildScopes is not supported, read actions (*/read) are not
860+
permitted, and Group type principals are not allowed.
861+
examples:
862+
- name: Create a deny assignment blocking role assignment writes for everyone, excluding a service principal.
863+
text: >-
864+
az role deny-assignment create
865+
--name "Block role assignment changes"
866+
--scope /subscriptions/00000000-0000-0000-0000-000000000000
867+
--actions "Microsoft.Authorization/roleAssignments/write" "Microsoft.Authorization/roleAssignments/delete"
868+
--exclude-principal-ids 00000000-0000-0000-0000-000000000001
869+
--exclude-principal-types ServicePrincipal
870+
- name: Create a deny assignment targeting a specific user.
871+
text: >-
872+
az role deny-assignment create
873+
--name "Deny resource deletion for user"
874+
--scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
875+
--actions "*/delete"
876+
--principal-object-id 00000000-0000-0000-0000-000000000001
877+
--principal-type User
878+
- name: Create a deny assignment targeting a specific service principal with exclusions.
879+
text: >-
880+
az role deny-assignment create
881+
--name "Deny write actions for app"
882+
--scope /subscriptions/00000000-0000-0000-0000-000000000000
883+
--actions "*/write"
884+
--principal-object-id 00000000-0000-0000-0000-000000000001
885+
--principal-type ServicePrincipal
886+
--exclude-principal-ids 00000000-0000-0000-0000-000000000002
887+
--exclude-principal-types ServicePrincipal
888+
--description "Block write operations for this application"
889+
"""
890+
891+
helps['role deny-assignment delete'] = """
892+
type: command
893+
short-summary: Delete a user-assigned deny assignment.
894+
examples:
895+
- name: Delete a deny assignment by its fully qualified ID.
896+
text: >-
897+
az role deny-assignment delete
898+
--id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001
899+
- name: Delete a deny assignment by name and scope.
900+
text: >-
901+
az role deny-assignment delete
902+
--name 00000000-0000-0000-0000-000000000001
903+
--scope /subscriptions/00000000-0000-0000-0000-000000000000
904+
"""
905+
816906
helps['role definition'] = """
817907
type: group
818908
short-summary: Manage role definitions.

src/azure-cli/azure/cli/command_modules/role/_params.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,60 @@ class PrincipalType(str, Enum):
390390
with self.argument_context('role assignment delete') as c:
391391
c.argument('yes', options_list=['--yes', '-y'], action='store_true', help='Currently no-op.')
392392

393+
with self.argument_context('role deny-assignment') as c:
394+
c.argument('scope', help='Scope at which the deny assignment applies. '
395+
'For example, /subscriptions/00000000-0000-0000-0000-000000000000 or '
396+
'/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup')
397+
c.argument('deny_assignment_name', options_list=['--name', '-n'],
398+
help='The display name of the deny assignment.')
399+
400+
with self.argument_context('role deny-assignment list') as c:
401+
c.argument('filter_str', options_list=['--filter'],
402+
help='OData filter expression to apply. For example, '
403+
'"atScope()" to list at the current scope, or '
404+
'"gdprExportPrincipalId eq \'{objectId}\'" to list for a specific principal.')
405+
406+
with self.argument_context('role deny-assignment show') as c:
407+
c.argument('deny_assignment_id', options_list=['--id'],
408+
help='The fully qualified ID of the deny assignment including scope, '
409+
'e.g. /subscriptions/{id}/providers/Microsoft.Authorization/denyAssignments/{denyAssignmentId}')
410+
c.argument('deny_assignment_name', options_list=['--name', '-n'],
411+
help='The name (GUID) of the deny assignment.')
412+
413+
with self.argument_context('role deny-assignment create') as c:
414+
c.argument('deny_assignment_name', options_list=['--name', '-n'],
415+
help='The display name of the deny assignment.')
416+
c.argument('description', help='Description of the deny assignment.')
417+
c.argument('actions', nargs='+',
418+
help='Space-separated list of actions to deny, e.g. '
419+
'"Microsoft.Authorization/roleAssignments/write". '
420+
'Note: read actions (*/read) are not permitted for user-assigned deny assignments.')
421+
c.argument('not_actions', nargs='+',
422+
help='Space-separated list of actions to exclude from the deny.')
423+
c.argument('principal_id', options_list=['--principal-object-id'],
424+
help='The object ID of a specific User or ServicePrincipal to deny. '
425+
'If omitted, the deny assignment applies to Everyone (all principals) and '
426+
'--exclude-principal-ids is required. Group principals are not permitted.')
427+
c.argument('principal_type', options_list=['--principal-type'],
428+
arg_type=get_enum_type(['User', 'ServicePrincipal']),
429+
help='The type of the principal specified by --principal-object-id. '
430+
'Required when --principal-object-id is provided. Accepted values: User, ServicePrincipal.')
431+
c.argument('exclude_principal_ids', nargs='+', options_list=['--exclude-principal-ids'],
432+
help='Space-separated list of principal object IDs to exclude from the deny. '
433+
'Required when no --principal-object-id is specified (Everyone mode). '
434+
'Optional when --principal-object-id is specified.')
435+
c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'],
436+
help='Space-separated list of principal types corresponding to --exclude-principal-ids. '
437+
'Accepted values: User, Group, ServicePrincipal.')
438+
c.argument('assignment_name', options_list=['--assignment-name'],
439+
help='A GUID for the deny assignment. If omitted, a new GUID is generated.')
440+
441+
with self.argument_context('role deny-assignment delete') as c:
442+
c.argument('deny_assignment_id', options_list=['--id'],
443+
help='The fully qualified ID of the deny assignment to delete.')
444+
c.argument('deny_assignment_name', options_list=['--name', '-n'],
445+
help='The name (GUID) of the deny assignment to delete.')
446+
393447
with self.argument_context('role definition') as c:
394448
c.argument('custom_role_only', arg_type=get_three_state_flag(), help='custom roles only(vs. build-in ones)')
395449
c.argument('role_definition', help="json formatted content which defines the new role.")

src/azure-cli/azure/cli/command_modules/role/commands.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ def transform_assignment_list(result):
2222
('Scope', r['scope'])]) for r in result]
2323

2424

25+
def transform_deny_assignment_list(result):
26+
return [OrderedDict([('Name', r.get('denyAssignmentName', '')),
27+
('Id', r.get('name', '')),
28+
('Scope', r.get('scope', ''))]) for r in result]
29+
30+
2531
def get_graph_object_transformer(object_type):
2632
selected_keys_for_type = {
2733
'app': ('displayName', 'id', 'appId', 'createdDateTime'),
@@ -78,6 +84,12 @@ def load_command_table(self, _):
7884
g.custom_command('update', 'update_role_assignment')
7985
g.custom_command('list-changelogs', 'list_role_assignment_change_logs')
8086

87+
with self.command_group('role deny-assignment') as g:
88+
g.custom_command('list', 'list_deny_assignments', table_transformer=transform_deny_assignment_list)
89+
g.custom_show_command('show', 'show_deny_assignment')
90+
g.custom_command('create', 'create_deny_assignment')
91+
g.custom_command('delete', 'delete_deny_assignment', confirmation=True)
92+
8193
with self.command_group('ad app', client_factory=get_graph_client, exception_handler=graph_err_handler) as g:
8294
g.custom_command('create', 'create_application')
8395
g.custom_command('delete', 'delete_application')

0 commit comments

Comments
 (0)