From 0a4e7205b3dd16d09bd881bd72361fefa9d83881 Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Tue, 31 Mar 2026 14:10:09 +0100 Subject: [PATCH 01/13] Add deny assignment create/delete CLI commands --- .../azure/cli/command_modules/role/_help.py | 80 +++++++++++ .../azure/cli/command_modules/role/_params.py | 45 +++++++ .../cli/command_modules/role/commands.py | 12 ++ .../azure/cli/command_modules/role/custom.py | 104 ++++++++++++++ .../role/linter_exclusions.yml | 8 ++ .../role/tests/latest/test_deny_assignment.py | 127 ++++++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py diff --git a/src/azure-cli/azure/cli/command_modules/role/_help.py b/src/azure-cli/azure/cli/command_modules/role/_help.py index 2163325534a..dc9b28f3a27 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_help.py +++ b/src/azure-cli/azure/cli/command_modules/role/_help.py @@ -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. diff --git a/src/azure-cli/azure/cli/command_modules/role/_params.py b/src/azure-cli/azure/cli/command_modules/role/_params.py index d015fd6f42b..a97946c508f 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_params.py +++ b/src/azure-cli/azure/cli/command_modules/role/_params.py @@ -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.') + + 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'], + 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.") diff --git a/src/azure-cli/azure/cli/command_modules/role/commands.py b/src/azure-cli/azure/cli/command_modules/role/commands.py index 9b07b8c105e..2487853195a 100644 --- a/src/azure-cli/azure/cli/command_modules/role/commands.py +++ b/src/azure-cli/azure/cli/command_modules/role/commands.py @@ -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'), @@ -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') diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index a36dbaf2bf9..4a94bbe1537 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -550,6 +550,110 @@ 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, deny_assignment_name, + 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 + """ + 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 + } + + return deny_client.create(scope=scope, deny_assignment_id=assignment_name, + parameters=deny_assignment_params) + + +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.') + + def _build_role_scope(resource_group_name, scope, subscription_id): subscription_scope = '/subscriptions/' + subscription_id if scope: diff --git a/src/azure-cli/azure/cli/command_modules/role/linter_exclusions.yml b/src/azure-cli/azure/cli/command_modules/role/linter_exclusions.yml index df90889b75c..e95a04c7196 100644 --- a/src/azure-cli/azure/cli/command_modules/role/linter_exclusions.yml +++ b/src/azure-cli/azure/cli/command_modules/role/linter_exclusions.yml @@ -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 ... diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py new file mode 100644 index 00000000000..3ea410bebea --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py @@ -0,0 +1,127 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# Test definitions for deny assignment commands (az role deny-assignment) +# These tests require a subscription with the UserAssignedDenyAssignment feature flag enabled. + +import unittest +from unittest import mock + +from azure.cli.testsdk import ScenarioTest, LiveScenarioTest + + +class DenyAssignmentListTest(ScenarioTest): + """Tests for az role deny-assignment list — works on any subscription.""" + + def test_deny_assignment_list(self): + """List deny assignments at the subscription scope.""" + result = self.cmd('role deny-assignment list').get_output_in_json() + # Result should be a list (may be empty if no deny assignments exist) + self.assertIsInstance(result, list) + + def test_deny_assignment_list_with_scope(self): + """List deny assignments at a specific scope.""" + self.cmd('role deny-assignment list --scope /subscriptions/{sub}', + checks=[self.check('type(@)', 'array')]) + + def test_deny_assignment_list_with_filter(self): + """List deny assignments with OData filter.""" + result = self.cmd( + 'role deny-assignment list --filter "atScope()"' + ).get_output_in_json() + self.assertIsInstance(result, list) + + +class DenyAssignmentShowTest(ScenarioTest): + """Tests for az role deny-assignment show.""" + + def test_deny_assignment_show_missing_args(self): + """Should fail if neither --id nor --name+--scope are provided.""" + with self.assertRaises(SystemExit): + self.cmd('role deny-assignment show') + + +class DenyAssignmentCrudTest(LiveScenarioTest): + """Full CRUD tests for user-assigned deny assignments. + + These are LiveScenarioTest because they require: + - A subscription with UserAssignedDenyAssignment feature flag enabled + - Real Azure API calls (PP1 feature, not in recordings) + """ + + def test_deny_assignment_create_and_delete(self): + """Create a deny assignment, show it, then delete it.""" + self.kwargs.update({ + 'scope': '/subscriptions/{sub}', + 'name': 'CLI Test Deny Assignment', + 'action': 'Microsoft.Authorization/roleAssignments/write', + # Use a well-known object ID for exclusion (replace with a real SP in your test env) + 'exclude_id': self.create_guid() + }) + + # Create + result = self.cmd( + 'role deny-assignment create ' + '--name "{name}" ' + '--scope {scope} ' + '--actions {action} ' + '--exclude-principal-ids {exclude_id} ' + '--exclude-principal-types ServicePrincipal ' + '--description "CLI test deny assignment"', + checks=[ + self.check('denyAssignmentName', '{name}'), + self.exists('name') + ] + ).get_output_in_json() + + self.kwargs['da_name'] = result['name'] + + # Show by name + scope + self.cmd( + 'role deny-assignment show --name {da_name} --scope {scope}', + checks=[ + self.check('denyAssignmentName', '{name}') + ] + ) + + # List should include our assignment + list_result = self.cmd( + 'role deny-assignment list --scope {scope}' + ).get_output_in_json() + self.assertTrue(any(da.get('name') == self.kwargs['da_name'] for da in list_result)) + + # Delete by name + scope + self.cmd('role deny-assignment delete --name {da_name} --scope {scope} --yes') + + def test_deny_assignment_create_validation_no_actions(self): + """Should fail if no actions are provided.""" + with self.assertRaises(SystemExit): + self.cmd( + 'role deny-assignment create ' + '--name "Test" ' + '--scope /subscriptions/{sub} ' + '--exclude-principal-ids 00000000-0000-0000-0000-000000000001' + ) + + def test_deny_assignment_create_validation_no_exclusions(self): + """Should fail if no excluded principals are provided.""" + with self.assertRaises(SystemExit): + self.cmd( + 'role deny-assignment create ' + '--name "Test" ' + '--scope /subscriptions/{sub} ' + '--actions "Microsoft.Authorization/roleAssignments/write"' + ) + + def test_deny_assignment_create_validation_read_action(self): + """Should fail if a read action is provided.""" + with self.assertRaises(SystemExit): + self.cmd( + 'role deny-assignment create ' + '--name "Test" ' + '--scope /subscriptions/{sub} ' + '--actions "Microsoft.Authorization/roleAssignments/read" ' + '--exclude-principal-ids 00000000-0000-0000-0000-000000000001' + ) From 1835bd35bdac928bf5a5395232092ff2881aa06d Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Tue, 31 Mar 2026 20:14:11 +0100 Subject: [PATCH 02/13] Fix flake8 E127 indentation in _params.py --- src/azure-cli/azure/cli/command_modules/role/_params.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/role/_params.py b/src/azure-cli/azure/cli/command_modules/role/_params.py index a97946c508f..6313c346922 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_params.py +++ b/src/azure-cli/azure/cli/command_modules/role/_params.py @@ -392,8 +392,8 @@ class PrincipalType(str, Enum): 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') + '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.') From 6c9f3029feb196b7d2f240a5ca314f2dd183da0a Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Tue, 31 Mar 2026 20:24:52 +0100 Subject: [PATCH 03/13] Fix unused import and add input validation --- src/azure-cli/azure/cli/command_modules/role/custom.py | 8 +++++++- .../role/tests/latest/test_deny_assignment.py | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index 4a94bbe1537..f0ed0806bdc 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -575,7 +575,7 @@ def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None raise CLIError('Please provide --id, or both --name and --scope.') -def create_deny_assignment(cmd, scope, deny_assignment_name, +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, @@ -589,6 +589,12 @@ def create_deny_assignment(cmd, scope, deny_assignment_name, - 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 diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py index 3ea410bebea..8c8189e5cef 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py @@ -7,7 +7,6 @@ # These tests require a subscription with the UserAssignedDenyAssignment feature flag enabled. import unittest -from unittest import mock from azure.cli.testsdk import ScenarioTest, LiveScenarioTest From 01b4d905411c5404678cda45c35bb6c648d61ddf Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Fri, 10 Apr 2026 10:36:30 +0100 Subject: [PATCH 04/13] 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> --- .../azure/cli/command_modules/role/_help.py | 32 +++-- .../azure/cli/command_modules/role/_params.py | 11 +- .../azure/cli/command_modules/role/custom.py | 61 ++++++---- .../role/tests/latest/test_deny_assignment.py | 111 ++++++++++++++++-- 4 files changed, 174 insertions(+), 41 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/role/_help.py b/src/azure-cli/azure/cli/command_modules/role/_help.py index dc9b28f3a27..d419470a5bd 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_help.py +++ b/src/azure-cli/azure/cli/command_modules/role/_help.py @@ -853,13 +853,13 @@ 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. + 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 that blocks role assignment writes, excluding a specific service principal. + - 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" @@ -867,15 +867,25 @@ --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. + - name: Create a deny assignment targeting a specific user. text: >- az role deny-assignment create - --name "Deny resource deletion" + --name "Deny resource deletion for user" --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 + --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'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/role/_params.py b/src/azure-cli/azure/cli/command_modules/role/_params.py index 6313c346922..91148a20733 100644 --- a/src/azure-cli/azure/cli/command_modules/role/_params.py +++ b/src/azure-cli/azure/cli/command_modules/role/_params.py @@ -420,9 +420,18 @@ class PrincipalType(str, Enum): '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. ' - 'At least one is required for user-assigned deny assignments.') + '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'], help='Space-separated list of principal types corresponding to --exclude-principal-ids. ' 'Accepted values: User, Group, ServicePrincipal.') diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index f0ed0806bdc..f643e9d047f 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -578,16 +578,22 @@ def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None 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 (PP1). + """Create a user-assigned deny assignment. - Under PP1 constraints: - - Principals is always Everyone (SystemDefined, 00000000-0000-0000-0000-000000000000) - - ExcludePrincipals is required (at least one) + 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.') @@ -601,33 +607,48 @@ def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, 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.") + # 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_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'}] + 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, diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py index 8c8189e5cef..73cd66bb32b 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py @@ -47,20 +47,19 @@ class DenyAssignmentCrudTest(LiveScenarioTest): These are LiveScenarioTest because they require: - A subscription with UserAssignedDenyAssignment feature flag enabled - - Real Azure API calls (PP1 feature, not in recordings) + - Real Azure API calls (not in recordings) """ - def test_deny_assignment_create_and_delete(self): - """Create a deny assignment, show it, then delete it.""" + def test_deny_assignment_create_everyone_and_delete(self): + """Create a deny assignment in Everyone mode (default), show it, then delete it.""" self.kwargs.update({ 'scope': '/subscriptions/{sub}', - 'name': 'CLI Test Deny Assignment', + 'name': 'CLI Test Deny Assignment Everyone', 'action': 'Microsoft.Authorization/roleAssignments/write', - # Use a well-known object ID for exclusion (replace with a real SP in your test env) 'exclude_id': self.create_guid() }) - # Create + # Create in Everyone mode (no --principal-id) result = self.cmd( 'role deny-assignment create ' '--name "{name}" ' @@ -68,7 +67,7 @@ def test_deny_assignment_create_and_delete(self): '--actions {action} ' '--exclude-principal-ids {exclude_id} ' '--exclude-principal-types ServicePrincipal ' - '--description "CLI test deny assignment"', + '--description "CLI test deny assignment - Everyone mode"', checks=[ self.check('denyAssignmentName', '{name}'), self.exists('name') @@ -94,6 +93,65 @@ def test_deny_assignment_create_and_delete(self): # Delete by name + scope self.cmd('role deny-assignment delete --name {da_name} --scope {scope} --yes') + def test_deny_assignment_create_per_principal_and_delete(self): + """Create a deny assignment targeting a specific User principal, then delete it.""" + self.kwargs.update({ + 'scope': '/subscriptions/{sub}', + 'name': 'CLI Test Deny Assignment Per-Principal', + 'action': 'Microsoft.Authorization/roleAssignments/write', + 'principal_id': self.create_guid() + }) + + # Create in per-principal mode + result = self.cmd( + 'role deny-assignment create ' + '--name "{name}" ' + '--scope {scope} ' + '--actions {action} ' + '--principal-id {principal_id} ' + '--principal-type User ' + '--description "CLI test deny assignment - per-principal mode"', + checks=[ + self.check('denyAssignmentName', '{name}'), + self.exists('name') + ] + ).get_output_in_json() + + self.kwargs['da_name'] = result['name'] + + # Delete + self.cmd('role deny-assignment delete --name {da_name} --scope {scope} --yes') + + def test_deny_assignment_create_per_principal_with_exclusions_and_delete(self): + """Create a per-principal deny assignment with exclude-principals, then delete it.""" + self.kwargs.update({ + 'scope': '/subscriptions/{sub}', + 'name': 'CLI Test Per-Principal With Exclusions', + 'action': 'Microsoft.Authorization/roleAssignments/write', + 'principal_id': self.create_guid(), + 'exclude_id': self.create_guid() + }) + + result = self.cmd( + 'role deny-assignment create ' + '--name "{name}" ' + '--scope {scope} ' + '--actions {action} ' + '--principal-id {principal_id} ' + '--principal-type ServicePrincipal ' + '--exclude-principal-ids {exclude_id} ' + '--exclude-principal-types ServicePrincipal ' + '--description "Per-principal with exclusions"', + checks=[ + self.check('denyAssignmentName', '{name}'), + self.exists('name') + ] + ).get_output_in_json() + + self.kwargs['da_name'] = result['name'] + + self.cmd('role deny-assignment delete --name {da_name} --scope {scope} --yes') + def test_deny_assignment_create_validation_no_actions(self): """Should fail if no actions are provided.""" with self.assertRaises(SystemExit): @@ -104,8 +162,8 @@ def test_deny_assignment_create_validation_no_actions(self): '--exclude-principal-ids 00000000-0000-0000-0000-000000000001' ) - def test_deny_assignment_create_validation_no_exclusions(self): - """Should fail if no excluded principals are provided.""" + def test_deny_assignment_create_validation_no_exclusions_everyone_mode(self): + """Should fail if no excluded principals are provided in Everyone mode.""" with self.assertRaises(SystemExit): self.cmd( 'role deny-assignment create ' @@ -124,3 +182,38 @@ def test_deny_assignment_create_validation_read_action(self): '--actions "Microsoft.Authorization/roleAssignments/read" ' '--exclude-principal-ids 00000000-0000-0000-0000-000000000001' ) + + def test_deny_assignment_create_validation_group_rejected(self): + """Should fail if Group principal type is specified.""" + with self.assertRaises(SystemExit): + self.cmd( + 'role deny-assignment create ' + '--name "Test" ' + '--scope /subscriptions/{sub} ' + '--actions "Microsoft.Authorization/roleAssignments/write" ' + '--principal-id 00000000-0000-0000-0000-000000000001 ' + '--principal-type Group' + ) + + def test_deny_assignment_create_validation_principal_type_required(self): + """Should fail if --principal-id is given without --principal-type.""" + with self.assertRaises(SystemExit): + self.cmd( + 'role deny-assignment create ' + '--name "Test" ' + '--scope /subscriptions/{sub} ' + '--actions "Microsoft.Authorization/roleAssignments/write" ' + '--principal-id 00000000-0000-0000-0000-000000000001' + ) + + def test_deny_assignment_create_validation_principal_id_required(self): + """Should fail if --principal-type is given without --principal-id.""" + with self.assertRaises(SystemExit): + self.cmd( + 'role deny-assignment create ' + '--name "Test" ' + '--scope /subscriptions/{sub} ' + '--actions "Microsoft.Authorization/roleAssignments/write" ' + '--principal-type User ' + '--exclude-principal-ids 00000000-0000-0000-0000-000000000001' + ) From 20fe3a79e375c80c167913e613f505dd905447d1 Mon Sep 17 00:00:00 2001 From: jruttle Date: Thu, 7 May 2026 13:55:31 +0100 Subject: [PATCH 05/13] Bump azure-mgmt-authorization to 5.0.0b2 Now that azure-mgmt-authorization 5.0.0b2 has been published to PyPI (2026-05-07), this picks up the new DenyAssignment management plane operations (BeginCreateOrUpdate / BeginDelete) added in PR #46223. This unblocks the deny-assignment CLI commands in this PR from running against the new SDK surface. --- src/azure-cli/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index fc8b6d736fd..3276d2da27e 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -68,7 +68,7 @@ 'azure-mgmt-appconfiguration==6.0.0b1', 'azure-mgmt-appcontainers==2.0.0', 'azure-mgmt-applicationinsights~=1.0.0', - 'azure-mgmt-authorization==5.0.0b1', + 'azure-mgmt-authorization==5.0.0b2', 'azure-mgmt-batchai==7.0.0b1', 'azure-mgmt-batch~=17.3.0', 'azure-mgmt-billing==6.0.0', From 71adba814fd38f369c3c0966b3d6e70963f823f2 Mon Sep 17 00:00:00 2001 From: jruttle Date: Thu, 7 May 2026 14:20:48 +0100 Subject: [PATCH 06/13] Bump azure-mgmt-authorization to 5.0.0b2 in lock files Follow-up to commit 20fe3a79 which only bumped setup.py. The Linux/Darwin/Windows requirements lock files also pinned 5.0.0b1 and caused azdev-linter / azdev-style CI to fail with: ERROR: Cannot install azure-cli==2.85.0 and azure-mgmt-authorization==5.0.0b1 because these package versions have conflicting dependencies. This commit aligns all three platform lock files with setup.py at 5.0.0b2. --- src/azure-cli/requirements.py3.Linux.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index b8bd4e1c701..52bfd525428 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -22,7 +22,7 @@ azure-mgmt-apimanagement==4.0.0 azure-mgmt-appconfiguration==6.0.0b1 azure-mgmt-appcontainers==2.0.0 azure-mgmt-applicationinsights==1.0.0 -azure-mgmt-authorization==5.0.0b1 +azure-mgmt-authorization==5.0.0b2 azure-mgmt-batch==17.3.0 azure-mgmt-batchai==7.0.0b1 azure-mgmt-billing==6.0.0 From 1139461380a063d419f6d9e62c656d51dd322644 Mon Sep 17 00:00:00 2001 From: jruttle Date: Thu, 7 May 2026 14:20:49 +0100 Subject: [PATCH 07/13] Bump azure-mgmt-authorization to 5.0.0b2 in requirements.py3.Darwin.txt --- src/azure-cli/requirements.py3.Darwin.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 6c0a4f50c63..78b6fa3e030 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -22,7 +22,7 @@ azure-mgmt-apimanagement==4.0.0 azure-mgmt-appconfiguration==6.0.0b1 azure-mgmt-appcontainers==2.0.0 azure-mgmt-applicationinsights==1.0.0 -azure-mgmt-authorization==5.0.0b1 +azure-mgmt-authorization==5.0.0b2 azure-mgmt-batch==17.3.0 azure-mgmt-batchai==7.0.0b1 azure-mgmt-billing==6.0.0 From 5c4caa2922f1e284bf0d983ee628f724c41f69ec Mon Sep 17 00:00:00 2001 From: jruttle Date: Thu, 7 May 2026 14:20:50 +0100 Subject: [PATCH 08/13] Bump azure-mgmt-authorization to 5.0.0b2 in requirements.py3.windows.txt --- src/azure-cli/requirements.py3.windows.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 61885e9af05..f2132862c88 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -22,7 +22,7 @@ azure-mgmt-apimanagement==4.0.0 azure-mgmt-appconfiguration==6.0.0b1 azure-mgmt-appcontainers==2.0.0 azure-mgmt-applicationinsights==1.0.0 -azure-mgmt-authorization==5.0.0b1 +azure-mgmt-authorization==5.0.0b2 azure-mgmt-batch==17.3.0 azure-mgmt-batchai==7.0.0b1 azure-mgmt-billing==6.0.0 From 2d2c80a0bb1ced0a3817b42439a6dc7f4fd773a9 Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Fri, 8 May 2026 16:14:26 +0100 Subject: [PATCH 09/13] Fix Full Test failures from azure-mgmt-authorization 5.0.0b2 SDK contract changes The new TypeSpec-generated 5.0.0b2 SDK introduced breaking changes that block the Full Test pipeline. Changes: * role/custom.py _resolve_role_id: pass `filter` as a keyword argument to RoleDefinitionsOperations.list (its signature is now `list(scope, *, filter=None)`). This single line was the root cause for the wide blast radius across vm/iot/aro/ams/acr/resource modules and the role module's own RoleAssignmentScenarioTest cases - all of them resolve roles by name and therefore go through this function. * role/custom.py update_role_assignment: replace `RoleAssignment.from_dict(d)` with `RoleAssignment(d)`. The new Model base class accepts a JSON mapping directly and no longer exposes from_dict. * role/custom.py create_deny_assignment: switch from passing a snake_case dict to constructing SDK model objects (DenyAssignment, DenyAssignmentProperties, DenyAssignmentPermission, DenyAssignmentPrincipal). The TypeSpec serializer would have written the raw snake_case keys to the wire instead of camelCase. Also rename `deny_client.create(...)` to `create_or_update(...)` (the method was renamed in the new SDK). * role/custom.py delete_deny_assignment: the new SDK does not expose delete_by_id for deny assignments, so parse the resource ID via a new _parse_deny_assignment_id helper (case-insensitive regex that handles subscription, resource-group, and management-group scopes) and call delete(scope, deny_assignment_id). * role/custom.py show_deny_assignment / delete_deny_assignment / create_deny_assignment: move argument validation ahead of the _auth_client_factory(...) call so validation-only error paths don't require an authenticated session. * tests/latest/test_deny_assignment.py: convert DenyAssignmentListTest to LiveScenarioTest (these hit the live API and have no recorded cassettes in the repo), and replace DenyAssignmentShowTest with a unittest-based DenyAssignmentShowValidationTest that calls show_deny_assignment() directly and asserts the CLIError - both changes remove the dependency on a logged-in session in playback CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/command_modules/role/custom.py | 142 +++++++++++------- .../role/tests/latest/test_deny_assignment.py | 27 +++- 2 files changed, 110 insertions(+), 59 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index f643e9d047f..4b376beb2cc 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -296,7 +296,9 @@ def update_role_assignment(cmd, role_assignment): RoleAssignment = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'RoleAssignment', mod='models', operation_group='role_assignments') - assignment = RoleAssignment.from_dict(role_assignment) + # In azure-mgmt-authorization 5.x (TypeSpec-generated), the new Model base class + # accepts a JSON mapping directly via __init__ and no longer exposes from_dict. + assignment = RoleAssignment(role_assignment) scope = assignment.scope name = assignment.name @@ -565,14 +567,16 @@ def list_deny_assignments(cmd, scope=None, filter_str=None): def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None, scope=None): """Get a deny assignment by ID or name.""" + # Validate args BEFORE creating the auth client so the missing-args path doesn't require login. + if not deny_assignment_id and not (deny_assignment_name and scope): + raise CLIError('Please provide --id, or both --name and --scope.') + 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.') + return deny_client.get(scope=scope, deny_assignment_id=deny_assignment_name) def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, @@ -595,15 +599,13 @@ def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, - Read actions (*/read) are not permitted - Group type principals are not permitted """ + # Validate args BEFORE creating the auth client so validation errors don't require login. 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.') @@ -613,7 +615,7 @@ def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, 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 + # Validate principal arguments and 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, ' @@ -625,60 +627,98 @@ def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, 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}] + elif not exclude_principal_ids: + # Everyone mode requires at least one exclusion + 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.') + + if exclude_principal_ids and 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.') + + # Resolve SDK model classes via get_sdk so the role module stays consistent with the rest of the codebase. + DenyAssignment = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignment', + mod='models', operation_group='deny_assignments') + DenyAssignmentProperties = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentProperties', + mod='models', operation_group='deny_assignments') + DenyAssignmentPermission = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPermission', + mod='models', operation_group='deny_assignments') + DenyAssignmentPrincipal = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPrincipal', + mod='models', operation_group='deny_assignments') + + # Build principals (target of the deny) + if principal_id: + principals = [DenyAssignmentPrincipal(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()) + # Everyone mode is represented by a single SystemDefined principal with the all-zero GUID. + principals = [DenyAssignmentPrincipal(id='00000000-0000-0000-0000-000000000000', type='SystemDefined')] - # Build exclude principals list + # Build exclude principals 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 - } + exclude_type = exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal' + exclude_principals.append(DenyAssignmentPrincipal(id=pid, type=exclude_type)) + + permission = DenyAssignmentPermission( + actions=actions or [], + not_actions=not_actions or [], + data_actions=[], + not_data_actions=[], + ) + + properties = DenyAssignmentProperties( + deny_assignment_name=deny_assignment_name, + description=description or '', + permissions=[permission], + principals=principals, + exclude_principals=exclude_principals, + is_system_protected=False, + ) - return deny_client.create(scope=scope, deny_assignment_id=assignment_name, - parameters=deny_assignment_params) + parameters = DenyAssignment(properties=properties) + if not assignment_name: + assignment_name = str(uuid.uuid4()) -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 + return deny_client.create_or_update(scope=scope, deny_assignment_id=assignment_name, + parameters=parameters) + + +def delete_deny_assignment(cmd, scope=None, deny_assignment_id=None, deny_assignment_name=None): + """Delete a user-assigned deny assignment.""" + # Validate args BEFORE creating the auth client so missing-args paths don't require login. + if not deny_assignment_id and not (deny_assignment_name and scope): + raise CLIError('Please provide --id, or both --name and --scope.') + 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.') + # The new SDK does not expose delete_by_id for deny assignments. Parse the canonical + # resource ID into (scope, name) and call delete(). Match case-insensitively because + # the provider segment may appear in any casing. + parsed_scope, parsed_name = _parse_deny_assignment_id(deny_assignment_id) + authorization_client = _auth_client_factory(cmd.cli_ctx, parsed_scope) + return authorization_client.deny_assignments.delete( + scope=parsed_scope, deny_assignment_id=parsed_name) + + authorization_client = _auth_client_factory(cmd.cli_ctx, scope) + return authorization_client.deny_assignments.delete( + scope=scope, deny_assignment_id=deny_assignment_name) + + +def _parse_deny_assignment_id(deny_assignment_id): + """Split a fully-qualified deny assignment resource ID into (scope, name).""" + match = re.match( + r'^(?P.+)/providers/Microsoft\.Authorization/denyAssignments/(?P[^/]+)$', + deny_assignment_id, re.IGNORECASE) + if not match: + raise CLIError( + "Invalid deny assignment ID '{}'. Expected format: " + "/providers/Microsoft.Authorization/denyAssignments/".format(deny_assignment_id)) + return match.group('scope'), match.group('name') def _build_role_scope(resource_group_name, scope, subscription_id): @@ -707,7 +747,7 @@ def _resolve_role_id(role, scope, definitions_client): role_id = '/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/{}'.format( definitions_client._config.subscription_id, role) if not role_id: # retrieve role id - role_defs = list(definitions_client.list(scope, "roleName eq '{}'".format(role))) + role_defs = list(definitions_client.list(scope, filter="roleName eq '{}'".format(role))) if not role_defs: raise CLIError("Role '{}' doesn't exist.".format(role)) if len(role_defs) > 1: diff --git a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py index 73cd66bb32b..da4d48da119 100644 --- a/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py @@ -8,11 +8,17 @@ import unittest -from azure.cli.testsdk import ScenarioTest, LiveScenarioTest +from knack.util import CLIError +from azure.cli.testsdk import LiveScenarioTest -class DenyAssignmentListTest(ScenarioTest): - """Tests for az role deny-assignment list — works on any subscription.""" + +class DenyAssignmentListTest(LiveScenarioTest): + """Tests for az role deny-assignment list — works on any subscription. + + These hit the live Authorization API (no recorded cassettes) so they run only in --live mode + and are skipped in the standard playback CI pipeline. + """ def test_deny_assignment_list(self): """List deny assignments at the subscription scope.""" @@ -33,13 +39,18 @@ def test_deny_assignment_list_with_filter(self): self.assertIsInstance(result, list) -class DenyAssignmentShowTest(ScenarioTest): - """Tests for az role deny-assignment show.""" +class DenyAssignmentShowValidationTest(unittest.TestCase): + """Pure-validation tests for show_deny_assignment that don't require Azure auth. + + Calls show_deny_assignment() directly so the missing-args validation runs before + any auth client is instantiated. + """ def test_deny_assignment_show_missing_args(self): - """Should fail if neither --id nor --name+--scope are provided.""" - with self.assertRaises(SystemExit): - self.cmd('role deny-assignment show') + """Should raise CLIError if neither --id nor --name+--scope are provided.""" + from azure.cli.command_modules.role.custom import show_deny_assignment + with self.assertRaisesRegex(CLIError, 'Please provide --id, or both --name and --scope'): + show_deny_assignment(cmd=None) class DenyAssignmentCrudTest(LiveScenarioTest): From f498063b72ebac448a2f09e3568127a63b50ff3b Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Fri, 8 May 2026 17:35:40 +0100 Subject: [PATCH 10/13] Refactor create_deny_assignment to satisfy R0914 too-many-locals Extract _build_deny_assignment_model helper to keep the local count under the project's max-locals=25 threshold (was 26/25 in azdev-style after the SDK-model refactor). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cli/command_modules/role/custom.py | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index 4b376beb2cc..6811ff0a5e8 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -637,56 +637,59 @@ def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, 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.') - # Resolve SDK model classes via get_sdk so the role module stays consistent with the rest of the codebase. - DenyAssignment = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignment', + parameters = _build_deny_assignment_model( + cmd.cli_ctx, deny_assignment_name, description, + actions, not_actions, principal_id, principal_type, + exclude_principal_ids, exclude_principal_types) + + if not assignment_name: + assignment_name = str(uuid.uuid4()) + + authorization_client = _auth_client_factory(cmd.cli_ctx, scope) + deny_client = authorization_client.deny_assignments + + return deny_client.create_or_update(scope=scope, deny_assignment_id=assignment_name, + parameters=parameters) + + +def _build_deny_assignment_model(cli_ctx, deny_assignment_name, description, + actions, not_actions, principal_id, principal_type, + exclude_principal_ids, exclude_principal_types): + """Construct the DenyAssignment SDK model from CLI inputs.""" + DenyAssignment = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignment', mod='models', operation_group='deny_assignments') - DenyAssignmentProperties = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentProperties', + DenyAssignmentProperties = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentProperties', mod='models', operation_group='deny_assignments') - DenyAssignmentPermission = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPermission', + DenyAssignmentPermission = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPermission', mod='models', operation_group='deny_assignments') - DenyAssignmentPrincipal = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPrincipal', + DenyAssignmentPrincipal = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPrincipal', mod='models', operation_group='deny_assignments') - # Build principals (target of the deny) if principal_id: principals = [DenyAssignmentPrincipal(id=principal_id, type=principal_type)] else: # Everyone mode is represented by a single SystemDefined principal with the all-zero GUID. principals = [DenyAssignmentPrincipal(id='00000000-0000-0000-0000-000000000000', type='SystemDefined')] - # Build exclude principals exclude_principals = [] if exclude_principal_ids: for i, pid in enumerate(exclude_principal_ids): exclude_type = exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal' exclude_principals.append(DenyAssignmentPrincipal(id=pid, type=exclude_type)) - permission = DenyAssignmentPermission( - actions=actions or [], - not_actions=not_actions or [], - data_actions=[], - not_data_actions=[], - ) - - properties = DenyAssignmentProperties( + return DenyAssignment(properties=DenyAssignmentProperties( deny_assignment_name=deny_assignment_name, description=description or '', - permissions=[permission], + permissions=[DenyAssignmentPermission( + actions=actions or [], + not_actions=not_actions or [], + data_actions=[], + not_data_actions=[], + )], principals=principals, exclude_principals=exclude_principals, is_system_protected=False, - ) - - parameters = DenyAssignment(properties=properties) - - if not assignment_name: - assignment_name = str(uuid.uuid4()) - - authorization_client = _auth_client_factory(cmd.cli_ctx, scope) - deny_client = authorization_client.deny_assignments - - return deny_client.create_or_update(scope=scope, deny_assignment_id=assignment_name, - parameters=parameters) + )) def delete_deny_assignment(cmd, scope=None, deny_assignment_id=None, deny_assignment_name=None): From 005aa582ab57b12b67bb821ffd322e8e5d9ed8ba Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Tue, 12 May 2026 09:54:08 +0100 Subject: [PATCH 11/13] Fix more azure-mgmt-authorization 5.0.0b2 SDK contract breaks Pipeline build 314889 (re-triggered 11 May) still fails because the 8 May patch did not address every breaking change in the new TypeSpec-generated SDK. This commit fixes the four remaining classes of issue. * role/custom.py update_role_assignment: `RoleAssignment(some_dict)` silently produces a model with `properties=None` (the new model wraps domain attributes under a nested `properties` field, with `__flattened_items` exposing them via descriptors only when `properties` is set). The old code then read `assignment.scope` -> None and passed scope=None to the SDK URL serializer, which raised `ValueError('No value for given attribute')`. Read scope/name/principalType/etc. directly from the user-supplied flat camelCase dict and send a `{"properties": {...}}` JSON body to .create() via its JSON overload - simpler and avoids the new model's flatten/unflatten gymnastics. Caused test_role_assignment_create_update. * role/custom.py list_role_assignments + list_deny_assignments + show_deny_assignment: `knack.util.todict` walks `__dict__` and therefore returns `{}` for the new MutableMapping-based models, so the subsequent `ra['roleDefinitionId']` / `ra['principalId']` lookups raised `KeyError`. Added explicit _role_assignment_to_dict and _deny_assignment_to_dict adapters that project the model back to the legacy flat camelCase shape (with enum -> str coercion) and routed all list/show paths through them. Caused test_create_for_rbac_password_with_assignment. * vm/_validators.py, ams/operations/sp.py, containerapp/_utils.py, acs/_roleassignments.py: `RoleDefinitionsOperations.list` is now `list(scope, *, filter=None)`, so each remaining caller that passed filter positionally raised `TypeError: list() takes 2 positional arguments but 3 were given`. This is the same bug fixed in 2d2c80a0 for role/custom.py::_resolve_role_id; these four call sites were missed. Caused test_vm_msi, test_vm_explicit_msi, test_vmss_msi, test_vmss_explicit_msi, test_ams_sp_create_reset, and the ACS / Container Apps role-resolution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../command_modules/acs/_roleassignments.py | 2 +- .../cli/command_modules/ams/operations/sp.py | 2 +- .../command_modules/containerapp/_utils.py | 2 +- .../azure/cli/command_modules/role/custom.py | 196 ++++++++++++++++-- .../cli/command_modules/vm/_validators.py | 2 +- 5 files changed, 183 insertions(+), 21 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/acs/_roleassignments.py b/src/azure-cli/azure/cli/command_modules/acs/_roleassignments.py index 98d6e51a2a1..4d276f92e2c 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_roleassignments.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_roleassignments.py @@ -39,7 +39,7 @@ def resolve_role_id(role, scope, definitions_client): pass if not role_id: # retrieve role id role_defs = list(definitions_client.list( - scope, "roleName eq '{}'".format(role))) + scope, filter="roleName eq '{}'".format(role))) if len(role_defs) == 0: raise AzCLIError("Role '{}' doesn't exist.".format(role)) if len(role_defs) > 1: diff --git a/src/azure-cli/azure/cli/command_modules/ams/operations/sp.py b/src/azure-cli/azure/cli/command_modules/ams/operations/sp.py index 4ea4672112a..cc1f7d5e330 100644 --- a/src/azure-cli/azure/cli/command_modules/ams/operations/sp.py +++ b/src/azure-cli/azure/cli/command_modules/ams/operations/sp.py @@ -214,7 +214,7 @@ def _resolve_role_id(cli_ctx, role, scope, definitions_client): role_id = '/subscriptions/{}/providers/Microsoft.Authorization/roleDefinitions/{}'.format( subscription_id, role) if not role_id: # retrieve role id - role_defs = list(definitions_client.list(scope, "roleName eq '{}'".format(role))) + role_defs = list(definitions_client.list(scope, filter="roleName eq '{}'".format(role))) if not role_defs: raise CLIError("Role '{}' doesn't exist.".format(role)) diff --git a/src/azure-cli/azure/cli/command_modules/containerapp/_utils.py b/src/azure-cli/azure/cli/command_modules/containerapp/_utils.py index 8cc498d4c6a..3d6354d0380 100644 --- a/src/azure-cli/azure/cli/command_modules/containerapp/_utils.py +++ b/src/azure-cli/azure/cli/command_modules/containerapp/_utils.py @@ -122,7 +122,7 @@ def _create_role_assignment(cli_ctx, role, assignee, scope=None): definitions_client = auth_client.role_definitions assignment_name = uuid.uuid4() - role_defs = list(definitions_client.list(scope, "roleName eq '{}'".format(role))) + role_defs = list(definitions_client.list(scope, filter="roleName eq '{}'".format(role))) role_id = role_defs[0].id api_version = supported_api_version(cli_ctx, resource_type=ResourceType.MGMT_AUTHORIZATION, max_api='2015-07-01') diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index 6811ff0a5e8..8adf06cdd1f 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -18,11 +18,12 @@ import os import re import uuid +from enum import Enum as _Enum import dateutil.parser from dateutil.relativedelta import relativedelta from knack.log import get_logger -from knack.util import CLIError, todict +from knack.util import CLIError from azure.core.exceptions import HttpResponseError from azure.cli.core.profiles import ResourceType, get_sdk @@ -32,6 +33,128 @@ from ._client_factory import _auth_client_factory, _graph_client_factory from ._msgrpah import GraphError, set_object_properties +# ---------------------------------------------------------------------------- +# SDK shape adapters +# ---------------------------------------------------------------------------- +# In azure-mgmt-authorization 5.x (TypeSpec-generated), RoleAssignment and DenyAssignment +# wrap their domain attributes in a nested ``properties`` envelope. The Track 1 SDK shape +# previously exposed everything at the top level, and downstream code (this module, table +# transformers, scenario tests) assumes that flat shape. ``knack.util.todict`` does not +# walk these new MutableMapping models (it inspects ``__dict__``), so we need explicit +# adapters to project them back to the legacy flat camelCase shape. + +def _coerce(value): + """Best-effort coerce an SDK attribute value to a JSON-safe scalar/list.""" + if value is None: + return None + if isinstance(value, _Enum): + return value.value + if isinstance(value, list): + return [_coerce(v) for v in value] + if isinstance(value, datetime.datetime): + return value.isoformat() + return value + + +_RA_FLAT_KEYS = ( + ('scope', 'scope'), + ('role_definition_id', 'roleDefinitionId'), + ('principal_id', 'principalId'), + ('principal_type', 'principalType'), + ('description', 'description'), + ('condition', 'condition'), + ('condition_version', 'conditionVersion'), + ('created_on', 'createdOn'), + ('updated_on', 'updatedOn'), + ('created_by', 'createdBy'), + ('updated_by', 'updatedBy'), + ('delegated_managed_identity_resource_id', 'delegatedManagedIdentityResourceId'), +) + + +def _role_assignment_to_dict(ra): + """Project a TypeSpec-generated RoleAssignment model to the legacy flat camelCase dict.""" + if ra is None: + return None + result = {} + for attr in ('id', 'name', 'type'): + value = getattr(ra, attr, None) + if value is not None: + result[attr] = _coerce(value) + for snake_attr, camel_key in _RA_FLAT_KEYS: + value = getattr(ra, snake_attr, None) + if value is not None: + result[camel_key] = _coerce(value) + return result + + +_DA_FLAT_KEYS = ( + ('deny_assignment_name', 'denyAssignmentName'), + ('description', 'description'), + ('scope', 'scope'), + ('do_not_apply_to_child_scopes', 'doNotApplyToChildScopes'), + ('is_system_protected', 'isSystemProtected'), + ('deny_assignment_effect', 'denyAssignmentEffect'), + ('condition', 'condition'), + ('condition_version', 'conditionVersion'), + ('created_on', 'createdOn'), + ('updated_on', 'updatedOn'), + ('created_by', 'createdBy'), + ('updated_by', 'updatedBy'), +) + + +def _deny_assignment_principal_to_dict(p): + if p is None: + return None + out = {} + for attr, key in (('id', 'id'), ('type', 'type'), ('display_name', 'displayName'), + ('email', 'email'), ('principal_type', 'principalType'), + ('object_type', 'objectType')): + value = getattr(p, attr, None) + if value is not None: + out[key] = _coerce(value) + return out + + +def _deny_assignment_permission_to_dict(perm): + if perm is None: + return None + out = {} + for attr, key in (('actions', 'actions'), ('not_actions', 'notActions'), + ('data_actions', 'dataActions'), ('not_data_actions', 'notDataActions'), + ('condition', 'condition'), ('condition_version', 'conditionVersion')): + value = getattr(perm, attr, None) + if value is not None: + out[key] = _coerce(value) + return out + + +def _deny_assignment_to_dict(da): + """Project a TypeSpec-generated DenyAssignment model to the legacy flat camelCase dict.""" + if da is None: + return None + result = {} + for attr in ('id', 'name', 'type'): + value = getattr(da, attr, None) + if value is not None: + result[attr] = _coerce(value) + for snake_attr, camel_key in _DA_FLAT_KEYS: + value = getattr(da, snake_attr, None) + if value is not None: + result[camel_key] = _coerce(value) + permissions = getattr(da, 'permissions', None) + if permissions is not None: + result['permissions'] = [_deny_assignment_permission_to_dict(p) for p in permissions] + principals = getattr(da, 'principals', None) + if principals is not None: + result['principals'] = [_deny_assignment_principal_to_dict(p) for p in principals] + excludes = getattr(da, 'exclude_principals', None) + if excludes is not None: + result['excludePrincipals'] = [_deny_assignment_principal_to_dict(p) for p in excludes] + return result + + # ARM RBAC's principalType USER = 'User' SERVICE_PRINCIPAL = 'ServicePrincipal' @@ -253,7 +376,10 @@ def list_role_assignments(cmd, # pylint: disable=too-many-locals, too-many-bran scope, assignee_object_id, role, include_inherited, include_groups) - results = todict(assignments) if assignments else [] + # In azure-mgmt-authorization 5.x the SDK models nest properties under a ``properties`` + # field and ``knack.util.todict`` does not walk MutableMapping models, so use the + # explicit adapter to keep the legacy flat camelCase shape downstream code expects. + results = [_role_assignment_to_dict(ra) for ra in assignments] if assignments else [] if not results: return [] @@ -294,13 +420,26 @@ def update_role_assignment(cmd, role_assignment): else: role_assignment = shell_safe_json_parse(role_assignment) - RoleAssignment = get_sdk(cmd.cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'RoleAssignment', mod='models', - operation_group='role_assignments') - # In azure-mgmt-authorization 5.x (TypeSpec-generated), the new Model base class - # accepts a JSON mapping directly via __init__ and no longer exposes from_dict. - assignment = RoleAssignment(role_assignment) - scope = assignment.scope - name = assignment.name + if not isinstance(role_assignment, dict): + raise CLIError("--role-assignment must be a JSON object.") + + # The user supplies the legacy flat camelCase shape (typically the output of + # ``az role assignment create`` or ``... show``). Read the resource address from + # there directly. The new TypeSpec-generated SDK does not reliably surface these + # fields when the model is constructed from a flat dict, so don't round-trip + # through ``RoleAssignment(...)`` to extract them. + scope = role_assignment.get('scope') + name = role_assignment.get('name') + if not scope or not name: + raise CLIError("'scope' and 'name' are required in the role assignment JSON.") + + new_principal_type = role_assignment.get('principalType') + new_condition = role_assignment.get('condition') + new_condition_version = role_assignment.get('conditionVersion') + new_description = role_assignment.get('description') + new_role_definition_id = role_assignment.get('roleDefinitionId') + new_principal_id = role_assignment.get('principalId') + new_delegated_id = role_assignment.get('delegatedManagedIdentityResourceId') auth_client = _auth_client_factory(cmd.cli_ctx, scope) assignments_client = auth_client.role_assignments @@ -310,14 +449,36 @@ def update_role_assignment(cmd, role_assignment): # Forbid condition version downgrading. # This should be implemented on the service-side in the future. - if (assignment.condition_version and original_assignment.condition_version and - original_assignment.condition_version.startswith('2.') and assignment.condition_version.startswith('1.')): + if (new_condition_version and original_assignment.condition_version and + original_assignment.condition_version.startswith('2.') and + new_condition_version.startswith('1.')): raise CLIError("Condition version cannot be downgraded to '1.X'.") - if not assignment.principal_type: - assignment.principal_type = original_assignment.principal_type + # PUT semantics: build a complete properties body, defaulting any field the user + # did not include to the existing value. + properties_body = { + 'roleDefinitionId': new_role_definition_id or original_assignment.role_definition_id, + 'principalId': new_principal_id or original_assignment.principal_id, + 'principalType': new_principal_type or original_assignment.principal_type, + 'description': new_description if new_description is not None + else original_assignment.description, + 'condition': new_condition if new_condition is not None + else original_assignment.condition, + 'conditionVersion': new_condition_version if new_condition_version is not None + else original_assignment.condition_version, + } + if new_delegated_id is not None or original_assignment.delegated_managed_identity_resource_id: + properties_body['delegatedManagedIdentityResourceId'] = ( + new_delegated_id or original_assignment.delegated_managed_identity_resource_id) + + # Strip None values so we don't clobber server defaults with explicit nulls. + properties_body = {k: v for k, v in properties_body.items() if v is not None} - return assignments_client.create(scope, name, parameters=assignment) + # The .create() overload accepts a JSON dict directly via the {"properties": {...}} + # envelope - simpler than wrapping in RoleAssignmentCreateParameters and avoids the + # flatten/unflatten gymnastics of the new TypeSpec models. + return _role_assignment_to_dict( + assignments_client.create(scope, name, parameters={'properties': properties_body})) def _get_assignment_events(cli_ctx, start_time=None, end_time=None): @@ -562,7 +723,7 @@ def list_deny_assignments(cmd, scope=None, filter_str=None): else: assignments = list(deny_client.list(filter=filter_str)) - return todict(assignments) if assignments else [] + return [_deny_assignment_to_dict(da) for da in assignments] if assignments else [] def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None, scope=None): @@ -575,8 +736,9 @@ def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None deny_client = authorization_client.deny_assignments if deny_assignment_id: - return deny_client.get_by_id(deny_assignment_id) - return deny_client.get(scope=scope, deny_assignment_id=deny_assignment_name) + return _deny_assignment_to_dict(deny_client.get_by_id(deny_assignment_id)) + return _deny_assignment_to_dict( + deny_client.get(scope=scope, deny_assignment_id=deny_assignment_name)) def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, diff --git a/src/azure-cli/azure/cli/command_modules/vm/_validators.py b/src/azure-cli/azure/cli/command_modules/vm/_validators.py index 602664133b6..acfb39ab1cd 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/vm/_validators.py @@ -1574,7 +1574,7 @@ def _resolve_role_id(cli_ctx, role, scope): except ValueError: pass if not role_id: # retrieve role id - role_defs = list(client.list(scope, "roleName eq '{}'".format(role))) + role_defs = list(client.list(scope, filter="roleName eq '{}'".format(role))) if not role_defs: raise CLIError("Role '{}' doesn't exist.".format(role)) if len(role_defs) > 1: From a78903e53ee36f6815401bae7b3674f8fafa54f9 Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Tue, 12 May 2026 10:26:12 +0100 Subject: [PATCH 12/13] Fix one more positional-filter caller surfaced by local test sweep While running the previously-failing tests locally to verify 005aa582ab, test_identity_hub continued to fail because azure-cli-core itself has its own resolve_role_id helper at src/azure-cli-core/azure/cli/core/commands/arm.py that also passed filter positionally to RoleDefinitionsOperations.list. This is the shared utility many modules call via core.commands.arm.create_role_assignment. Same one-line fix as the four module-level callers patched in 005aa582ab: pass filter as a keyword argument so it works with the new TypeSpec-generated SDK signature `list(scope, *, filter=None)`. Local verification (azure-mgmt-authorization 5.0.0b2 installed): * test_role_assignment_create_update PASSED (was: ValueError) * test_role_assignment_scenario PASSED * test_role_assignment_create_using_principal_type PASSED * test_role_assignment_handle_conflicted_assignments PASSED * test_create_for_rbac_password_with_assignment PASSED (was: KeyError) * test_vm_msi PASSED (was: TypeError) * test_vm_explicit_msi PASSED * test_vmss_msi PASSED * test_vmss_explicit_msi PASSED * test_identity_hub PASSED (was: TypeError - this commit's fix) * Full role test sweep: 17 passed, 2 LiveOnly skipped * Role unit tests: 14 passed test_ams_sp_create_reset still fails with VCR cassette mismatch (the cassette was recorded against azure-mgmt-authorization 4.x but dev branch bumped to 5.0.0b1 in #31859 without re-recording AMS cassettes; multiple identical requests now exhaust the single recorded response). This is pre-existing on dev and outside the scope of this PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/azure-cli-core/azure/cli/core/commands/arm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/arm.py b/src/azure-cli-core/azure/cli/core/commands/arm.py index 802efc7bc48..d570c9340f8 100644 --- a/src/azure-cli-core/azure/cli/core/commands/arm.py +++ b/src/azure-cli-core/azure/cli/core/commands/arm.py @@ -818,7 +818,7 @@ def resolve_role_id(cli_ctx, role, scope): except ValueError: pass if not role_id: # retrieve role id - role_defs = list(client.list(scope, "roleName eq '{}'".format(role))) + role_defs = list(client.list(scope, filter="roleName eq '{}'".format(role))) if not role_defs: raise CLIError("Role '{}' doesn't exist.".format(role)) if len(role_defs) > 1: From 90642428ce01a448c596d46db6d576a722c34433 Mon Sep 17 00:00:00 2001 From: Jonathan Ruttle Date: Tue, 12 May 2026 13:18:14 +0100 Subject: [PATCH 13/13] Fix flake8 E302: add second blank line before _coerce helper The azdev-style GitHub Actions check (run id 25725694563) failed flake8 on the new `_coerce` function added in 005aa582ab because it followed the section comment block with only one blank line. Adding the second blank line resolves the two reported E302 errors at line 46. Verified locally with `azdev style --pep8` -> Flake8: PASSED. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/azure-cli/azure/cli/command_modules/role/custom.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/azure-cli/azure/cli/command_modules/role/custom.py b/src/azure-cli/azure/cli/command_modules/role/custom.py index 8adf06cdd1f..9d99763e7a9 100644 --- a/src/azure-cli/azure/cli/command_modules/role/custom.py +++ b/src/azure-cli/azure/cli/command_modules/role/custom.py @@ -43,6 +43,7 @@ # walk these new MutableMapping models (it inspects ``__dict__``), so we need explicit # adapters to project them back to the legacy flat camelCase shape. + def _coerce(value): """Best-effort coerce an SDK attribute value to a JSON-safe scalar/list.""" if value is None: