Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
80 changes: 80 additions & 0 deletions src/azure-cli/azure/cli/command_modules/role/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,86 @@
short-summary: List changelogs for role assignments.
"""

helps['role deny-assignment'] = """
type: group
short-summary: Manage deny assignments.
long-summary: >-
Deny assignments block users from performing specific Azure resource actions even if a role assignment
grants them access. User-assigned deny assignments can be created to deny write, delete, and action
operations at a given scope while excluding specific principals.
"""

helps['role deny-assignment list'] = """
type: command
short-summary: List deny assignments.
examples:
- name: List deny assignments at the subscription scope.
text: az role deny-assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000
- name: List all deny assignments in the current subscription.
text: az role deny-assignment list
- name: List deny assignments at a resource group scope.
text: az role deny-assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
"""

helps['role deny-assignment show'] = """
type: command
short-summary: Get a deny assignment.
examples:
- name: Show a deny assignment by its fully qualified ID.
text: >-
az role deny-assignment show
--id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001
- name: Show a deny assignment by name and scope.
text: >-
az role deny-assignment show
--name 00000000-0000-0000-0000-000000000001
--scope /subscriptions/00000000-0000-0000-0000-000000000000
"""

helps['role deny-assignment create'] = """
type: command
short-summary: Create a user-assigned deny assignment.
long-summary: >-
Creates a deny assignment that blocks specific actions for all principals at the given scope,
excluding the specified principals. This is a PP1 (Private Preview 1) feature with the following constraints:
principals are always Everyone (SystemDefined), at least one excluded principal is required,
DataActions are not supported, DoNotApplyToChildScopes is not supported, and read actions (*/read)
are not permitted.
examples:
- name: Create a deny assignment that blocks role assignment writes, excluding a specific service principal.
text: >-
az role deny-assignment create
--name "Block role assignment changes"
--scope /subscriptions/00000000-0000-0000-0000-000000000000
--actions "Microsoft.Authorization/roleAssignments/write" "Microsoft.Authorization/roleAssignments/delete"
--exclude-principal-ids 00000000-0000-0000-0000-000000000001
--exclude-principal-types ServicePrincipal
- name: Create a deny assignment with multiple excluded principals and a description.
text: >-
az role deny-assignment create
--name "Deny resource deletion"
--scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
--actions "*/delete"
--description "Prevent accidental resource deletion"
--exclude-principal-ids 00000000-0000-0000-0000-000000000001 00000000-0000-0000-0000-000000000002
--exclude-principal-types ServicePrincipal User
"""

helps['role deny-assignment delete'] = """
type: command
short-summary: Delete a user-assigned deny assignment.
examples:
- name: Delete a deny assignment by its fully qualified ID.
text: >-
az role deny-assignment delete
--id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001
- name: Delete a deny assignment by name and scope.
text: >-
az role deny-assignment delete
--name 00000000-0000-0000-0000-000000000001
--scope /subscriptions/00000000-0000-0000-0000-000000000000
"""

helps['role definition'] = """
type: group
short-summary: Manage role definitions.
Expand Down
45 changes: 45 additions & 0 deletions src/azure-cli/azure/cli/command_modules/role/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,51 @@ class PrincipalType(str, Enum):
with self.argument_context('role assignment delete') as c:
c.argument('yes', options_list=['--yes', '-y'], action='store_true', help='Currently no-op.')

with self.argument_context('role deny-assignment') as c:
c.argument('scope', help='Scope at which the deny assignment applies. '
'For example, /subscriptions/00000000-0000-0000-0000-000000000000 or '
'/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup')
c.argument('deny_assignment_name', options_list=['--name', '-n'],
help='The display name of the deny assignment.')
Comment on lines +397 to +398
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

deny_assignment_name is defined at the role deny-assignment group level, which makes --name/-n show up for subcommands like list even though list_deny_assignments doesn't accept that parameter. If a user supplies --name on list, the handler will receive an unexpected kwarg and fail. Recommend removing deny_assignment_name from the group context and defining --name only on show/create/delete where it is supported.

Suggested change
c.argument('deny_assignment_name', options_list=['--name', '-n'],
help='The display name of the deny assignment.')

Copilot uses AI. Check for mistakes.

with self.argument_context('role deny-assignment list') as c:
c.argument('filter_str', options_list=['--filter'],
help='OData filter expression to apply. For example, '
'"atScope()" to list at the current scope, or '
'"gdprExportPrincipalId eq \'{objectId}\'" to list for a specific principal.')

with self.argument_context('role deny-assignment show') as c:
c.argument('deny_assignment_id', options_list=['--id'],
help='The fully qualified ID of the deny assignment including scope, '
'e.g. /subscriptions/{id}/providers/Microsoft.Authorization/denyAssignments/{denyAssignmentId}')
c.argument('deny_assignment_name', options_list=['--name', '-n'],
help='The name (GUID) of the deny assignment.')

with self.argument_context('role deny-assignment create') as c:
c.argument('deny_assignment_name', options_list=['--name', '-n'],
help='The display name of the deny assignment.')
c.argument('description', help='Description of the deny assignment.')
c.argument('actions', nargs='+',
help='Space-separated list of actions to deny, e.g. '
'"Microsoft.Authorization/roleAssignments/write". '
'Note: read actions (*/read) are not permitted for user-assigned deny assignments.')
c.argument('not_actions', nargs='+',
help='Space-separated list of actions to exclude from the deny.')
c.argument('exclude_principal_ids', nargs='+', options_list=['--exclude-principal-ids'],
help='Space-separated list of principal object IDs to exclude from the deny. '
'At least one is required for user-assigned deny assignments.')
c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'],
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

--exclude-principal-types is documented as having accepted values, but the argument doesn't enforce them. To keep validation consistent with role assignment create --assignee-principal-type, use arg_type=get_enum_type([...]) (or an Enum) so invalid values are caught client-side with a clear error.

Suggested change
c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'],
c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'],
arg_type=get_enum_type(['User', 'Group', 'ServicePrincipal']),

Copilot uses AI. Check for mistakes.
help='Space-separated list of principal types corresponding to --exclude-principal-ids. '
'Accepted values: User, Group, ServicePrincipal.')
c.argument('assignment_name', options_list=['--assignment-name'],
help='A GUID for the deny assignment. If omitted, a new GUID is generated.')

with self.argument_context('role deny-assignment delete') as c:
c.argument('deny_assignment_id', options_list=['--id'],
help='The fully qualified ID of the deny assignment to delete.')
c.argument('deny_assignment_name', options_list=['--name', '-n'],
help='The name (GUID) of the deny assignment to delete.')

with self.argument_context('role definition') as c:
c.argument('custom_role_only', arg_type=get_three_state_flag(), help='custom roles only(vs. build-in ones)')
c.argument('role_definition', help="json formatted content which defines the new role.")
Expand Down
12 changes: 12 additions & 0 deletions src/azure-cli/azure/cli/command_modules/role/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ def transform_assignment_list(result):
('Scope', r['scope'])]) for r in result]


def transform_deny_assignment_list(result):
return [OrderedDict([('Name', r.get('denyAssignmentName', '')),
('Id', r.get('name', '')),
('Scope', r.get('scope', ''))]) for r in result]


def get_graph_object_transformer(object_type):
selected_keys_for_type = {
'app': ('displayName', 'id', 'appId', 'createdDateTime'),
Expand Down Expand Up @@ -78,6 +84,12 @@ def load_command_table(self, _):
g.custom_command('update', 'update_role_assignment')
g.custom_command('list-changelogs', 'list_role_assignment_change_logs')

with self.command_group('role deny-assignment') as g:
g.custom_command('list', 'list_deny_assignments', table_transformer=transform_deny_assignment_list)
g.custom_show_command('show', 'show_deny_assignment')
g.custom_command('create', 'create_deny_assignment')
g.custom_command('delete', 'delete_deny_assignment', confirmation=True)

with self.command_group('ad app', client_factory=get_graph_client, exception_handler=graph_err_handler) as g:
g.custom_command('create', 'create_application')
g.custom_command('delete', 'delete_application')
Expand Down
110 changes: 110 additions & 0 deletions src/azure-cli/azure/cli/command_modules/role/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,116 @@ def _search_role_assignments(assignments_client, definitions_client,
return assignments


def list_deny_assignments(cmd, scope=None, filter_str=None):
"""List deny assignments at a scope or for the entire subscription."""
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
deny_client = authorization_client.deny_assignments

if scope:
assignments = list(deny_client.list_for_scope(scope=scope, filter=filter_str))
else:
assignments = list(deny_client.list(filter=filter_str))

return todict(assignments) if assignments else []


def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None, scope=None):
"""Get a deny assignment by ID or name."""
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
deny_client = authorization_client.deny_assignments

if deny_assignment_id:
return deny_client.get_by_id(deny_assignment_id)
if deny_assignment_name and scope:
return deny_client.get(scope=scope, deny_assignment_id=deny_assignment_name)
raise CLIError('Please provide --id, or both --name and --scope.')


def create_deny_assignment(cmd, scope=None, deny_assignment_name=None,
actions=None, not_actions=None,
description=None,
exclude_principal_ids=None, exclude_principal_types=None,
assignment_name=None):
"""Create a user-assigned deny assignment (PP1).

Under PP1 constraints:
- Principals is always Everyone (SystemDefined, 00000000-0000-0000-0000-000000000000)
- ExcludePrincipals is required (at least one)
- DataActions and NotDataActions are not supported
- DoNotApplyToChildScopes is not supported
- Read actions (*/read) are not permitted
"""
if not scope:
raise CLIError('--scope is required for creating a deny assignment.')

if not deny_assignment_name:
raise CLIError('--name is required for creating a deny assignment.')

authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
deny_client = authorization_client.deny_assignments

if not actions:
raise CLIError('At least one action is required via --actions.')

if not exclude_principal_ids:
raise CLIError('At least one excluded principal is required via --exclude-principal-ids. '
'User-assigned deny assignments deny Everyone and require at least one exclusion.')

# Validate no read actions
for action in actions:
if action.lower().endswith('/read'):
raise CLIError(f"Read actions are not permitted for user-assigned deny assignments: '{action}'. "
"Only write, delete, and action operations can be denied.")

if not assignment_name:
assignment_name = str(uuid.uuid4())

# Build exclude principals list
exclude_principals = []
if exclude_principal_types and len(exclude_principal_types) != len(exclude_principal_ids):
raise CLIError('--exclude-principal-types must have the same number of entries as --exclude-principal-ids.')

for i, pid in enumerate(exclude_principal_ids):
principal = {
'id': pid,
'type': exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal'
}
exclude_principals.append(principal)

# PP1: Principals must be Everyone (SystemDefined)
principals = [{'id': '00000000-0000-0000-0000-000000000000', 'type': 'SystemDefined'}]

deny_assignment_params = {
'deny_assignment_name': deny_assignment_name,
'description': description or '',
'permissions': [{
'actions': actions or [],
'not_actions': not_actions or [],
'data_actions': [],
'not_data_actions': []
}],
'scope': scope,
'principals': principals,
'exclude_principals': exclude_principals,
'is_system_protected': False
Comment on lines +633 to +644
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

create_deny_assignment builds the request body as a plain dict using snake_case keys (e.g. deny_assignment_name, not_actions, exclude_principals). For mgmt SDK operations, dicts are typically serialized as-is, so the service will receive incorrect field names (it expects camelCase JSON, or a proper SDK model instance). Use the azure-mgmt-authorization model types via get_sdk(..., mod='models') (similar to RoleApiHelper.create_role_assignment) or ensure the payload keys match the service JSON contract exactly.

Suggested change
'deny_assignment_name': deny_assignment_name,
'description': description or '',
'permissions': [{
'actions': actions or [],
'not_actions': not_actions or [],
'data_actions': [],
'not_data_actions': []
}],
'scope': scope,
'principals': principals,
'exclude_principals': exclude_principals,
'is_system_protected': False
'denyAssignmentName': deny_assignment_name,
'description': description or '',
'permissions': [{
'actions': actions or [],
'notActions': not_actions or [],
'dataActions': [],
'notDataActions': []
}],
'scope': scope,
'principals': principals,
'excludePrincipals': exclude_principals,
'isSystemProtected': False

Copilot uses AI. Check for mistakes.
}

return deny_client.create(scope=scope, deny_assignment_id=assignment_name,
parameters=deny_assignment_params)
Comment on lines +647 to +648
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

This calls authorization_client.deny_assignments.create(...), but the repo currently pins azure-mgmt-authorization==5.0.0b1 (which does not include denyAssignments create/delete per the PR description). Without bumping the SDK dependency (or adding a fallback implementation / friendly error), this command will raise AttributeError at runtime.

Copilot uses AI. Check for mistakes.


def delete_deny_assignment(cmd, scope=None, deny_assignment_id=None, deny_assignment_name=None):
"""Delete a user-assigned deny assignment."""
authorization_client = _auth_client_factory(cmd.cli_ctx, scope)
deny_client = authorization_client.deny_assignments

if deny_assignment_id:
return deny_client.delete_by_id(deny_assignment_id)
if deny_assignment_name and scope:
return deny_client.delete(scope=scope, deny_assignment_id=deny_assignment_name)
raise CLIError('Please provide --id, or both --name and --scope.')
Comment on lines +651 to +660
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Same dependency issue as create: deny_client.delete(...)/delete_by_id(...) will fail at runtime unless the pinned azure-mgmt-authorization version includes these methods. Consider either updating the dependency in this PR or detecting missing methods and raising a clear CLIError instructing users to upgrade.

Copilot uses AI. Check for mistakes.


def _build_role_scope(resource_group_name, scope, subscription_id):
subscription_scope = '/subscriptions/' + subscription_id
if scope:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,12 @@ ad user get-member-groups:
security_enabled_only:
rule_exclusions:
- option_length_too_long
role deny-assignment create:
parameters:
exclude_principal_ids:
rule_exclusions:
- option_length_too_long
exclude_principal_types:
rule_exclusions:
- option_length_too_long
...
Loading
Loading