Skip to content

Commit ae59ca3

Browse files
Jonathan RuttleCopilot
andcommitted
Support per-principal deny assignments (User/ServicePrincipal)
Update create command to support two modes: - Everyone mode (default): denies all principals, requires exclude-principal-ids - Per-principal mode: denies a specific User or ServicePrincipal via --principal-id/--principal-type API changes from DA PR msazure/One#15293894: - 3P UADA can now target specific User and ServicePrincipal principals - Group type principals are explicitly disallowed - Single-principal-per-UADA constraint enforced Changes: - custom.py: Add principal_id/principal_type params, dual-mode logic, Group rejection - _params.py: Add --principal-id and --principal-type (enum) arguments - _help.py: Update long-summary and examples for both modes - tests: Add per-principal CRUD, Group rejection, missing-param validation tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6c9f302 commit ae59ca3

File tree

4 files changed

+144
-41
lines changed

4 files changed

+144
-41
lines changed

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -853,29 +853,39 @@
853853
type: command
854854
short-summary: Create a user-assigned deny assignment.
855855
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.
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-id.
859+
DataActions are not supported, DoNotApplyToChildScopes is not supported, read actions (*/read) are not
860+
permitted, and Group type principals are not allowed.
861861
examples:
862-
- name: Create a deny assignment that blocks role assignment writes, excluding a specific service principal.
862+
- name: Create a deny assignment blocking role assignment writes for everyone, excluding a service principal.
863863
text: >-
864864
az role deny-assignment create
865865
--name "Block role assignment changes"
866866
--scope /subscriptions/00000000-0000-0000-0000-000000000000
867867
--actions "Microsoft.Authorization/roleAssignments/write" "Microsoft.Authorization/roleAssignments/delete"
868868
--exclude-principal-ids 00000000-0000-0000-0000-000000000001
869869
--exclude-principal-types ServicePrincipal
870-
- name: Create a deny assignment with multiple excluded principals and a description.
870+
- name: Create a deny assignment targeting a specific user.
871871
text: >-
872872
az role deny-assignment create
873-
--name "Deny resource deletion"
873+
--name "Deny resource deletion for user"
874874
--scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
875875
--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
876+
--principal-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-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"
879889
"""
880890

881891
helps['role deny-assignment delete'] = """

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,18 @@ class PrincipalType(str, Enum):
420420
'Note: read actions (*/read) are not permitted for user-assigned deny assignments.')
421421
c.argument('not_actions', nargs='+',
422422
help='Space-separated list of actions to exclude from the deny.')
423+
c.argument('principal_id', options_list=['--principal-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-id. '
430+
'Required when --principal-id is provided. Accepted values: User, ServicePrincipal.')
423431
c.argument('exclude_principal_ids', nargs='+', options_list=['--exclude-principal-ids'],
424432
help='Space-separated list of principal object IDs to exclude from the deny. '
425-
'At least one is required for user-assigned deny assignments.')
433+
'Required when no --principal-id is specified (Everyone mode). '
434+
'Optional when --principal-id is specified.')
426435
c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'],
427436
help='Space-separated list of principal types corresponding to --exclude-principal-ids. '
428437
'Accepted values: User, Group, ServicePrincipal.')

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

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -578,16 +578,22 @@ def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None
578578
def create_deny_assignment(cmd, scope=None, deny_assignment_name=None,
579579
actions=None, not_actions=None,
580580
description=None,
581+
principal_id=None, principal_type=None,
581582
exclude_principal_ids=None, exclude_principal_types=None,
582583
assignment_name=None):
583-
"""Create a user-assigned deny assignment (PP1).
584+
"""Create a user-assigned deny assignment.
584585
585-
Under PP1 constraints:
586-
- Principals is always Everyone (SystemDefined, 00000000-0000-0000-0000-000000000000)
587-
- ExcludePrincipals is required (at least one)
586+
Two modes are supported:
587+
- Everyone mode (default): Denies actions for all principals at the scope. Requires at least one
588+
excluded principal via --exclude-principal-ids.
589+
- Per-principal mode: Denies actions for a specific User or ServicePrincipal. Specify the target
590+
with --principal-id and --principal-type. Excluded principals are optional in this mode.
591+
592+
Constraints:
588593
- DataActions and NotDataActions are not supported
589594
- DoNotApplyToChildScopes is not supported
590595
- Read actions (*/read) are not permitted
596+
- Group type principals are not permitted
591597
"""
592598
if not scope:
593599
raise CLIError('--scope is required for creating a deny assignment.')
@@ -601,33 +607,48 @@ def create_deny_assignment(cmd, scope=None, deny_assignment_name=None,
601607
if not actions:
602608
raise CLIError('At least one action is required via --actions.')
603609

604-
if not exclude_principal_ids:
605-
raise CLIError('At least one excluded principal is required via --exclude-principal-ids. '
606-
'User-assigned deny assignments deny Everyone and require at least one exclusion.')
607-
608610
# Validate no read actions
609611
for action in actions:
610612
if action.lower().endswith('/read'):
611613
raise CLIError(f"Read actions are not permitted for user-assigned deny assignments: '{action}'. "
612614
"Only write, delete, and action operations can be denied.")
613615

616+
# Build principals list
617+
if principal_type and not principal_id:
618+
raise CLIError('--principal-id is required when --principal-type is specified. '
619+
'Provide both --principal-id and --principal-type together, '
620+
'or omit both for Everyone mode.')
621+
if principal_id:
622+
if not principal_type:
623+
raise CLIError('--principal-type is required when --principal-id is specified. '
624+
'Accepted values: User, ServicePrincipal.')
625+
if principal_type == 'Group':
626+
raise CLIError('Group type principals are not permitted for user-assigned deny assignments. '
627+
'Use User or ServicePrincipal instead.')
628+
principals = [{'id': principal_id, 'type': principal_type}]
629+
else:
630+
# Everyone mode — deny applies to all principals at the scope
631+
if not exclude_principal_ids:
632+
raise CLIError('At least one excluded principal is required via --exclude-principal-ids '
633+
'when using Everyone mode (no --principal-id specified). '
634+
'User-assigned deny assignments that deny Everyone require at least one exclusion.')
635+
principals = [{'id': '00000000-0000-0000-0000-000000000000', 'type': 'SystemDefined'}]
636+
614637
if not assignment_name:
615638
assignment_name = str(uuid.uuid4())
616639

617640
# Build exclude principals list
618641
exclude_principals = []
619-
if exclude_principal_types and len(exclude_principal_types) != len(exclude_principal_ids):
620-
raise CLIError('--exclude-principal-types must have the same number of entries as --exclude-principal-ids.')
621-
622-
for i, pid in enumerate(exclude_principal_ids):
623-
principal = {
624-
'id': pid,
625-
'type': exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal'
626-
}
627-
exclude_principals.append(principal)
628-
629-
# PP1: Principals must be Everyone (SystemDefined)
630-
principals = [{'id': '00000000-0000-0000-0000-000000000000', 'type': 'SystemDefined'}]
642+
if exclude_principal_ids:
643+
if exclude_principal_types and len(exclude_principal_types) != len(exclude_principal_ids):
644+
raise CLIError('--exclude-principal-types must have the same number of entries as --exclude-principal-ids.')
645+
646+
for i, pid in enumerate(exclude_principal_ids):
647+
principal = {
648+
'id': pid,
649+
'type': exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal'
650+
}
651+
exclude_principals.append(principal)
631652

632653
deny_assignment_params = {
633654
'deny_assignment_name': deny_assignment_name,

src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,27 @@ class DenyAssignmentCrudTest(LiveScenarioTest):
4747
4848
These are LiveScenarioTest because they require:
4949
- A subscription with UserAssignedDenyAssignment feature flag enabled
50-
- Real Azure API calls (PP1 feature, not in recordings)
50+
- Real Azure API calls (not in recordings)
5151
"""
5252

53-
def test_deny_assignment_create_and_delete(self):
54-
"""Create a deny assignment, show it, then delete it."""
53+
def test_deny_assignment_create_everyone_and_delete(self):
54+
"""Create a deny assignment in Everyone mode (default), show it, then delete it."""
5555
self.kwargs.update({
5656
'scope': '/subscriptions/{sub}',
57-
'name': 'CLI Test Deny Assignment',
57+
'name': 'CLI Test Deny Assignment Everyone',
5858
'action': 'Microsoft.Authorization/roleAssignments/write',
59-
# Use a well-known object ID for exclusion (replace with a real SP in your test env)
6059
'exclude_id': self.create_guid()
6160
})
6261

63-
# Create
62+
# Create in Everyone mode (no --principal-id)
6463
result = self.cmd(
6564
'role deny-assignment create '
6665
'--name "{name}" '
6766
'--scope {scope} '
6867
'--actions {action} '
6968
'--exclude-principal-ids {exclude_id} '
7069
'--exclude-principal-types ServicePrincipal '
71-
'--description "CLI test deny assignment"',
70+
'--description "CLI test deny assignment - Everyone mode"',
7271
checks=[
7372
self.check('denyAssignmentName', '{name}'),
7473
self.exists('name')
@@ -94,6 +93,35 @@ def test_deny_assignment_create_and_delete(self):
9493
# Delete by name + scope
9594
self.cmd('role deny-assignment delete --name {da_name} --scope {scope} --yes')
9695

96+
def test_deny_assignment_create_per_principal_and_delete(self):
97+
"""Create a deny assignment targeting a specific User principal, then delete it."""
98+
self.kwargs.update({
99+
'scope': '/subscriptions/{sub}',
100+
'name': 'CLI Test Deny Assignment Per-Principal',
101+
'action': 'Microsoft.Authorization/roleAssignments/write',
102+
'principal_id': self.create_guid()
103+
})
104+
105+
# Create in per-principal mode
106+
result = self.cmd(
107+
'role deny-assignment create '
108+
'--name "{name}" '
109+
'--scope {scope} '
110+
'--actions {action} '
111+
'--principal-id {principal_id} '
112+
'--principal-type User '
113+
'--description "CLI test deny assignment - per-principal mode"',
114+
checks=[
115+
self.check('denyAssignmentName', '{name}'),
116+
self.exists('name')
117+
]
118+
).get_output_in_json()
119+
120+
self.kwargs['da_name'] = result['name']
121+
122+
# Delete
123+
self.cmd('role deny-assignment delete --name {da_name} --scope {scope} --yes')
124+
97125
def test_deny_assignment_create_validation_no_actions(self):
98126
"""Should fail if no actions are provided."""
99127
with self.assertRaises(SystemExit):
@@ -104,8 +132,8 @@ def test_deny_assignment_create_validation_no_actions(self):
104132
'--exclude-principal-ids 00000000-0000-0000-0000-000000000001'
105133
)
106134

107-
def test_deny_assignment_create_validation_no_exclusions(self):
108-
"""Should fail if no excluded principals are provided."""
135+
def test_deny_assignment_create_validation_no_exclusions_everyone_mode(self):
136+
"""Should fail if no excluded principals are provided in Everyone mode."""
109137
with self.assertRaises(SystemExit):
110138
self.cmd(
111139
'role deny-assignment create '
@@ -124,3 +152,38 @@ def test_deny_assignment_create_validation_read_action(self):
124152
'--actions "Microsoft.Authorization/roleAssignments/read" '
125153
'--exclude-principal-ids 00000000-0000-0000-0000-000000000001'
126154
)
155+
156+
def test_deny_assignment_create_validation_group_rejected(self):
157+
"""Should fail if Group principal type is specified."""
158+
with self.assertRaises(SystemExit):
159+
self.cmd(
160+
'role deny-assignment create '
161+
'--name "Test" '
162+
'--scope /subscriptions/{sub} '
163+
'--actions "Microsoft.Authorization/roleAssignments/write" '
164+
'--principal-id 00000000-0000-0000-0000-000000000001 '
165+
'--principal-type Group'
166+
)
167+
168+
def test_deny_assignment_create_validation_principal_type_required(self):
169+
"""Should fail if --principal-id is given without --principal-type."""
170+
with self.assertRaises(SystemExit):
171+
self.cmd(
172+
'role deny-assignment create '
173+
'--name "Test" '
174+
'--scope /subscriptions/{sub} '
175+
'--actions "Microsoft.Authorization/roleAssignments/write" '
176+
'--principal-id 00000000-0000-0000-0000-000000000001'
177+
)
178+
179+
def test_deny_assignment_create_validation_principal_id_required(self):
180+
"""Should fail if --principal-type is given without --principal-id."""
181+
with self.assertRaises(SystemExit):
182+
self.cmd(
183+
'role deny-assignment create '
184+
'--name "Test" '
185+
'--scope /subscriptions/{sub} '
186+
'--actions "Microsoft.Authorization/roleAssignments/write" '
187+
'--principal-type User '
188+
'--exclude-principal-ids 00000000-0000-0000-0000-000000000001'
189+
)

0 commit comments

Comments
 (0)