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..6313c346922 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..f0ed0806bdc 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,116 @@ def _search_role_assignments(assignments_client, definitions_client, return assignments +def list_deny_assignments(cmd, scope=None, filter_str=None): + """List deny assignments at a scope or for the entire subscription.""" + authorization_client = _auth_client_factory(cmd.cli_ctx, scope) + deny_client = authorization_client.deny_assignments + + if scope: + assignments = list(deny_client.list_for_scope(scope=scope, filter=filter_str)) + else: + assignments = list(deny_client.list(filter=filter_str)) + + return todict(assignments) if assignments else [] + + +def show_deny_assignment(cmd, deny_assignment_id=None, deny_assignment_name=None, scope=None): + """Get a deny assignment by ID or name.""" + authorization_client = _auth_client_factory(cmd.cli_ctx, scope) + deny_client = authorization_client.deny_assignments + + if deny_assignment_id: + return deny_client.get_by_id(deny_assignment_id) + if deny_assignment_name and scope: + return deny_client.get(scope=scope, deny_assignment_id=deny_assignment_name) + raise CLIError('Please provide --id, or both --name and --scope.') + + +def create_deny_assignment(cmd, scope=None, deny_assignment_name=None, + actions=None, not_actions=None, + description=None, + exclude_principal_ids=None, exclude_principal_types=None, + assignment_name=None): + """Create a user-assigned deny assignment (PP1). + + Under PP1 constraints: + - Principals is always Everyone (SystemDefined, 00000000-0000-0000-0000-000000000000) + - ExcludePrincipals is required (at least one) + - DataActions and NotDataActions are not supported + - DoNotApplyToChildScopes is not supported + - Read actions (*/read) are not permitted + """ + if not scope: + raise CLIError('--scope is required for creating a deny assignment.') + + if not deny_assignment_name: + raise CLIError('--name is required for creating a deny assignment.') + + authorization_client = _auth_client_factory(cmd.cli_ctx, scope) + deny_client = authorization_client.deny_assignments + + if not actions: + raise CLIError('At least one action is required via --actions.') + + if not exclude_principal_ids: + raise CLIError('At least one excluded principal is required via --exclude-principal-ids. ' + 'User-assigned deny assignments deny Everyone and require at least one exclusion.') + + # Validate no read actions + for action in actions: + if action.lower().endswith('/read'): + raise CLIError(f"Read actions are not permitted for user-assigned deny assignments: '{action}'. " + "Only write, delete, and action operations can be denied.") + + if not assignment_name: + assignment_name = str(uuid.uuid4()) + + # Build exclude principals list + exclude_principals = [] + if exclude_principal_types and len(exclude_principal_types) != len(exclude_principal_ids): + raise CLIError('--exclude-principal-types must have the same number of entries as --exclude-principal-ids.') + + for i, pid in enumerate(exclude_principal_ids): + principal = { + 'id': pid, + 'type': exclude_principal_types[i] if exclude_principal_types else 'ServicePrincipal' + } + exclude_principals.append(principal) + + # PP1: Principals must be Everyone (SystemDefined) + principals = [{'id': '00000000-0000-0000-0000-000000000000', 'type': 'SystemDefined'}] + + deny_assignment_params = { + 'deny_assignment_name': deny_assignment_name, + 'description': description or '', + 'permissions': [{ + 'actions': actions or [], + 'not_actions': not_actions or [], + 'data_actions': [], + 'not_data_actions': [] + }], + 'scope': scope, + 'principals': principals, + 'exclude_principals': exclude_principals, + 'is_system_protected': False + } + + 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..8c8189e5cef --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py @@ -0,0 +1,126 @@ +# -------------------------------------------------------------------------------------------- +# 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 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' + )