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
90 changes: 90 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,96 @@
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 at the given scope. Two modes are supported:
(1) Everyone mode (default) — denies actions for all principals, requiring at least one excluded principal;
(2) Per-principal mode — denies actions for a specific User or ServicePrincipal specified via --principal-id.
DataActions are not supported, DoNotApplyToChildScopes is not supported, read actions (*/read) are not
permitted, and Group type principals are not allowed.
examples:
- name: Create a deny assignment blocking role assignment writes for everyone, excluding a 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 targeting a specific user.
text: >-
az role deny-assignment create
--name "Deny resource deletion for user"
--scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup
--actions "*/delete"
--principal-id 00000000-0000-0000-0000-000000000001
--principal-type User
- name: Create a deny assignment targeting a specific service principal with exclusions.
text: >-
az role deny-assignment create
--name "Deny write actions for app"
--scope /subscriptions/00000000-0000-0000-0000-000000000000
--actions "*/write"
--principal-id 00000000-0000-0000-0000-000000000001
--principal-type ServicePrincipal
--exclude-principal-ids 00000000-0000-0000-0000-000000000002
--exclude-principal-types ServicePrincipal
--description "Block write operations for this application"
"""

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
54 changes: 54 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,60 @@ 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('principal_id', options_list=['--principal-id'],
help='The object ID of a specific User or ServicePrincipal to deny. '
'If omitted, the deny assignment applies to Everyone (all principals) and '
'--exclude-principal-ids is required. Group principals are not permitted.')
c.argument('principal_type', options_list=['--principal-type'],
arg_type=get_enum_type(['User', 'ServicePrincipal']),
help='The type of the principal specified by --principal-id. '
'Required when --principal-id is provided. Accepted values: User, ServicePrincipal.')
c.argument('exclude_principal_ids', nargs='+', options_list=['--exclude-principal-ids'],
help='Space-separated list of principal object IDs to exclude from the deny. '
'Required when no --principal-id is specified (Everyone mode). '
'Optional when --principal-id is specified.')
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
131 changes: 131 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,137 @@ 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,
principal_id=None, principal_type=None,
exclude_principal_ids=None, exclude_principal_types=None,
assignment_name=None):
"""Create a user-assigned deny assignment.

Two modes are supported:
- Everyone mode (default): Denies actions for all principals at the scope. Requires at least one
excluded principal via --exclude-principal-ids.
- Per-principal mode: Denies actions for a specific User or ServicePrincipal. Specify the target
with --principal-id and --principal-type. Excluded principals are optional in this mode.

Constraints:
- DataActions and NotDataActions are not supported
- DoNotApplyToChildScopes is not supported
- Read actions (*/read) are not permitted
- Group type principals 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.')

# 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.")

# Build principals list
if principal_type and not principal_id:
raise CLIError('--principal-id is required when --principal-type is specified. '
'Provide both --principal-id and --principal-type together, '
'or omit both for Everyone mode.')
if principal_id:
if not principal_type:
raise CLIError('--principal-type is required when --principal-id is specified. '
'Accepted values: User, ServicePrincipal.')
if principal_type == 'Group':
raise CLIError('Group type principals are not permitted for user-assigned deny assignments. '
'Use User or ServicePrincipal instead.')
principals = [{'id': principal_id, 'type': principal_type}]
else:
# Everyone mode — deny applies to all principals at the scope
if not exclude_principal_ids:
raise CLIError('At least one excluded principal is required via --exclude-principal-ids '
'when using Everyone mode (no --principal-id specified). '
'User-assigned deny assignments that deny Everyone require at least one exclusion.')
principals = [{'id': '00000000-0000-0000-0000-000000000000', 'type': 'SystemDefined'}]

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

# Build exclude principals list
exclude_principals = []
if exclude_principal_ids:
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)

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 +654 to +665
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 +668 to +669
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 +672 to +681
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