Skip to content

Commit 0a4e720

Browse files
author
Jonathan Ruttle
committed
Add deny assignment create/delete CLI commands
1 parent 692b407 commit 0a4e720

6 files changed

Lines changed: 376 additions & 0 deletions

File tree

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,86 @@
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 for all principals at the given scope,
857+
excluding the specified principals. This is a PP1 (Private Preview 1) feature with the following constraints:
858+
principals are always Everyone (SystemDefined), at least one excluded principal is required,
859+
DataActions are not supported, DoNotApplyToChildScopes is not supported, and read actions (*/read)
860+
are not permitted.
861+
examples:
862+
- name: Create a deny assignment that blocks role assignment writes, excluding a specific 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 with multiple excluded principals and a description.
871+
text: >-
872+
az role deny-assignment create
873+
--name "Deny resource deletion"
874+
--scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
875+
--actions "*/delete"
876+
--description "Prevent accidental resource deletion"
877+
--exclude-principal-ids 00000000-0000-0000-0000-000000000001 00000000-0000-0000-0000-000000000002
878+
--exclude-principal-types ServicePrincipal User
879+
"""
880+
881+
helps['role deny-assignment delete'] = """
882+
type: command
883+
short-summary: Delete a user-assigned deny assignment.
884+
examples:
885+
- name: Delete a deny assignment by its fully qualified ID.
886+
text: >-
887+
az role deny-assignment delete
888+
--id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001
889+
- name: Delete a deny assignment by name and scope.
890+
text: >-
891+
az role deny-assignment delete
892+
--name 00000000-0000-0000-0000-000000000001
893+
--scope /subscriptions/00000000-0000-0000-0000-000000000000
894+
"""
895+
816896
helps['role definition'] = """
817897
type: group
818898
short-summary: Manage role definitions.

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,51 @@ 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('exclude_principal_ids', nargs='+', options_list=['--exclude-principal-ids'],
424+
help='Space-separated list of principal object IDs to exclude from the deny. '
425+
'At least one is required for user-assigned deny assignments.')
426+
c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'],
427+
help='Space-separated list of principal types corresponding to --exclude-principal-ids. '
428+
'Accepted values: User, Group, ServicePrincipal.')
429+
c.argument('assignment_name', options_list=['--assignment-name'],
430+
help='A GUID for the deny assignment. If omitted, a new GUID is generated.')
431+
432+
with self.argument_context('role deny-assignment delete') as c:
433+
c.argument('deny_assignment_id', options_list=['--id'],
434+
help='The fully qualified ID of the deny assignment to delete.')
435+
c.argument('deny_assignment_name', options_list=['--name', '-n'],
436+
help='The name (GUID) of the deny assignment to delete.')
437+
393438
with self.argument_context('role definition') as c:
394439
c.argument('custom_role_only', arg_type=get_three_state_flag(), help='custom roles only(vs. build-in ones)')
395440
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')

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,110 @@ def _search_role_assignments(assignments_client, definitions_client,
550550
return assignments
551551

552552

553+
def list_deny_assignments(cmd, scope=None, filter_str=None):
554+
"""List deny assignments at a scope or for the entire subscription."""
555+
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
556+
deny_client = authorization_client.deny_assignments
557+
558+
if scope:
559+
assignments = list(deny_client.list_for_scope(scope=scope, filter=filter_str))
560+
else:
561+
assignments = list(deny_client.list(filter=filter_str))
562+
563+
return todict(assignments) if assignments else []
564+
565+
566+
def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None, scope=None):
567+
"""Get a deny assignment by ID or name."""
568+
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
569+
deny_client = authorization_client.deny_assignments
570+
571+
if deny_assignment_id:
572+
return deny_client.get_by_id(deny_assignment_id)
573+
if deny_assignment_name and scope:
574+
return deny_client.get(scope=scope, deny_assignment_id=deny_assignment_name)
575+
raise CLIError('Please provide --id, or both --name and --scope.')
576+
577+
578+
def create_deny_assignment(cmd, scope, deny_assignment_name,
579+
actions=None, not_actions=None,
580+
description=None,
581+
exclude_principal_ids=None, exclude_principal_types=None,
582+
assignment_name=None):
583+
"""Create a user-assigned deny assignment (PP1).
584+
585+
Under PP1 constraints:
586+
- Principals is always Everyone (SystemDefined, 00000000-0000-0000-0000-000000000000)
587+
- ExcludePrincipals is required (at least one)
588+
- DataActions and NotDataActions are not supported
589+
- DoNotApplyToChildScopes is not supported
590+
- Read actions (*/read) are not permitted
591+
"""
592+
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
593+
deny_client = authorization_client.deny_assignments
594+
595+
if not actions:
596+
raise CLIError('At least one action is required via --actions.')
597+
598+
if not exclude_principal_ids:
599+
raise CLIError('At least one excluded principal is required via --exclude-principal-ids. '
600+
'User-assigned deny assignments deny Everyone and require at least one exclusion.')
601+
602+
# Validate no read actions
603+
for action in actions:
604+
if action.lower().endswith('/read'):
605+
raise CLIError(f"Read actions are not permitted for user-assigned deny assignments: '{action}'. "
606+
"Only write, delete, and action operations can be denied.")
607+
608+
if not assignment_name:
609+
assignment_name = str(uuid.uuid4())
610+
611+
# Build exclude principals list
612+
exclude_principals = []
613+
if exclude_principal_types and len(exclude_principal_types) != len(exclude_principal_ids):
614+
raise CLIError('--exclude-principal-types must have the same number of entries as --exclude-principal-ids.')
615+
616+
for i, pid in enumerate(exclude_principal_ids):
617+
principal = {
618+
'id': pid,
619+
'type': exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal'
620+
}
621+
exclude_principals.append(principal)
622+
623+
# PP1: Principals must be Everyone (SystemDefined)
624+
principals = [{'id': '00000000-0000-0000-0000-000000000000', 'type': 'SystemDefined'}]
625+
626+
deny_assignment_params = {
627+
'deny_assignment_name': deny_assignment_name,
628+
'description': description or '',
629+
'permissions': [{
630+
'actions': actions or [],
631+
'not_actions': not_actions or [],
632+
'data_actions': [],
633+
'not_data_actions': []
634+
}],
635+
'scope': scope,
636+
'principals': principals,
637+
'exclude_principals': exclude_principals,
638+
'is_system_protected': False
639+
}
640+
641+
return deny_client.create(scope=scope, deny_assignment_id=assignment_name,
642+
parameters=deny_assignment_params)
643+
644+
645+
def delete_deny_assignment(cmd, scope=None, deny_assignment_id=None, deny_assignment_name=None):
646+
"""Delete a user-assigned deny assignment."""
647+
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
648+
deny_client = authorization_client.deny_assignments
649+
650+
if deny_assignment_id:
651+
return deny_client.delete_by_id(deny_assignment_id)
652+
if deny_assignment_name and scope:
653+
return deny_client.delete(scope=scope, deny_assignment_id=deny_assignment_name)
654+
raise CLIError('Please provide --id, or both --name and --scope.')
655+
656+
553657
def _build_role_scope(resource_group_name, scope, subscription_id):
554658
subscription_scope = '/subscriptions/' + subscription_id
555659
if scope:

src/azure-cli/azure/cli/command_modules/role/linter_exclusions.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,12 @@ ad user get-member-groups:
103103
security_enabled_only:
104104
rule_exclusions:
105105
- option_length_too_long
106+
role deny-assignment create:
107+
parameters:
108+
exclude_principal_ids:
109+
rule_exclusions:
110+
- option_length_too_long
111+
exclude_principal_types:
112+
rule_exclusions:
113+
- option_length_too_long
106114
...

0 commit comments

Comments
 (0)