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: 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/_help.py b/src/azure-cli/azure/cli/command_modules/role/_help.py index 2163325534a..d419470a5bd 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,96 @@ short-summary: List changelogs for role assignments. """ +helps['role deny-assignment'] = """ +type: group +short-summary: Manage deny assignments. +long-summary: >- + Deny assignments block users from performing specific Azure resource actions even if a role assignment + grants them access. User-assigned deny assignments can be created to deny write, delete, and action + operations at a given scope while excluding specific principals. +""" + +helps['role deny-assignment list'] = """ +type: command +short-summary: List deny assignments. +examples: + - name: List deny assignments at the subscription scope. + text: az role deny-assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000 + - name: List all deny assignments in the current subscription. + text: az role deny-assignment list + - name: List deny assignments at a resource group scope. + text: az role deny-assignment list --scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup +""" + +helps['role deny-assignment show'] = """ +type: command +short-summary: Get a deny assignment. +examples: + - name: Show a deny assignment by its fully qualified ID. + text: >- + az role deny-assignment show + --id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001 + - name: Show a deny assignment by name and scope. + text: >- + az role deny-assignment show + --name 00000000-0000-0000-0000-000000000001 + --scope /subscriptions/00000000-0000-0000-0000-000000000000 +""" + +helps['role deny-assignment create'] = """ +type: command +short-summary: Create a user-assigned deny assignment. +long-summary: >- + Creates a deny assignment that blocks specific actions at the given scope. Two modes are supported: + (1) Everyone mode (default) — denies actions for all principals, requiring at least one excluded principal; + (2) Per-principal mode — denies actions for a specific User or ServicePrincipal specified via --principal-id. + DataActions are not supported, DoNotApplyToChildScopes is not supported, read actions (*/read) are not + permitted, and Group type principals are not allowed. +examples: + - name: Create a deny assignment blocking role assignment writes for everyone, excluding a service principal. + text: >- + az role deny-assignment create + --name "Block role assignment changes" + --scope /subscriptions/00000000-0000-0000-0000-000000000000 + --actions "Microsoft.Authorization/roleAssignments/write" "Microsoft.Authorization/roleAssignments/delete" + --exclude-principal-ids 00000000-0000-0000-0000-000000000001 + --exclude-principal-types ServicePrincipal + - name: Create a deny assignment targeting a specific user. + text: >- + az role deny-assignment create + --name "Deny resource deletion for user" + --scope /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup + --actions "*/delete" + --principal-id 00000000-0000-0000-0000-000000000001 + --principal-type User + - name: Create a deny assignment targeting a specific service principal with exclusions. + text: >- + az role deny-assignment create + --name "Deny write actions for app" + --scope /subscriptions/00000000-0000-0000-0000-000000000000 + --actions "*/write" + --principal-id 00000000-0000-0000-0000-000000000001 + --principal-type ServicePrincipal + --exclude-principal-ids 00000000-0000-0000-0000-000000000002 + --exclude-principal-types ServicePrincipal + --description "Block write operations for this application" +""" + +helps['role deny-assignment delete'] = """ +type: command +short-summary: Delete a user-assigned deny assignment. +examples: + - name: Delete a deny assignment by its fully qualified ID. + text: >- + az role deny-assignment delete + --id /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/denyAssignments/00000000-0000-0000-0000-000000000001 + - name: Delete a deny assignment by name and scope. + text: >- + az role deny-assignment delete + --name 00000000-0000-0000-0000-000000000001 + --scope /subscriptions/00000000-0000-0000-0000-000000000000 +""" + helps['role definition'] = """ type: group short-summary: Manage role definitions. 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..91148a20733 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,60 @@ class PrincipalType(str, Enum): with self.argument_context('role assignment delete') as c: c.argument('yes', options_list=['--yes', '-y'], action='store_true', help='Currently no-op.') + with self.argument_context('role deny-assignment') as c: + c.argument('scope', help='Scope at which the deny assignment applies. ' + 'For example, /subscriptions/00000000-0000-0000-0000-000000000000 or ' + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myGroup') + c.argument('deny_assignment_name', options_list=['--name', '-n'], + help='The display name of the deny assignment.') + + with self.argument_context('role deny-assignment list') as c: + c.argument('filter_str', options_list=['--filter'], + help='OData filter expression to apply. For example, ' + '"atScope()" to list at the current scope, or ' + '"gdprExportPrincipalId eq \'{objectId}\'" to list for a specific principal.') + + with self.argument_context('role deny-assignment show') as c: + c.argument('deny_assignment_id', options_list=['--id'], + help='The fully qualified ID of the deny assignment including scope, ' + 'e.g. /subscriptions/{id}/providers/Microsoft.Authorization/denyAssignments/{denyAssignmentId}') + c.argument('deny_assignment_name', options_list=['--name', '-n'], + help='The name (GUID) of the deny assignment.') + + with self.argument_context('role deny-assignment create') as c: + c.argument('deny_assignment_name', options_list=['--name', '-n'], + help='The display name of the deny assignment.') + c.argument('description', help='Description of the deny assignment.') + c.argument('actions', nargs='+', + help='Space-separated list of actions to deny, e.g. ' + '"Microsoft.Authorization/roleAssignments/write". ' + 'Note: read actions (*/read) are not permitted for user-assigned deny assignments.') + c.argument('not_actions', nargs='+', + help='Space-separated list of actions to exclude from the deny.') + c.argument('principal_id', options_list=['--principal-id'], + help='The object ID of a specific User or ServicePrincipal to deny. ' + 'If omitted, the deny assignment applies to Everyone (all principals) and ' + '--exclude-principal-ids is required. Group principals are not permitted.') + c.argument('principal_type', options_list=['--principal-type'], + arg_type=get_enum_type(['User', 'ServicePrincipal']), + help='The type of the principal specified by --principal-id. ' + 'Required when --principal-id is provided. Accepted values: User, ServicePrincipal.') + c.argument('exclude_principal_ids', nargs='+', options_list=['--exclude-principal-ids'], + help='Space-separated list of principal object IDs to exclude from the deny. ' + 'Required when no --principal-id is specified (Everyone mode). ' + 'Optional when --principal-id is specified.') + c.argument('exclude_principal_types', nargs='+', options_list=['--exclude-principal-types'], + 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..9d99763e7a9 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,129 @@ 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 +377,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,11 +421,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') - assignment = RoleAssignment.from_dict(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 @@ -308,14 +450,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): @@ -550,6 +714,179 @@ 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 [_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): + """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_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, + actions=None, not_actions=None, + description=None, + principal_id=None, principal_type=None, + exclude_principal_ids=None, exclude_principal_types=None, + assignment_name=None): + """Create a user-assigned deny assignment. + + Two modes are supported: + - Everyone mode (default): Denies actions for all principals at the scope. Requires at least one + excluded principal via --exclude-principal-ids. + - Per-principal mode: Denies actions for a specific User or ServicePrincipal. Specify the target + with --principal-id and --principal-type. Excluded principals are optional in this mode. + + Constraints: + - DataActions and NotDataActions are not supported + - DoNotApplyToChildScopes is not supported + - Read actions (*/read) are not permitted + - Group type principals are not permitted + """ + # 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.') + + if not actions: + raise CLIError('At least one action is required via --actions.') + + # Validate no read actions + for action in actions: + if action.lower().endswith('/read'): + raise CLIError(f"Read actions are not permitted for user-assigned deny assignments: '{action}'. " + "Only write, delete, and action operations can be denied.") + + # 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, ' + '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.') + 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.') + + 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(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentProperties', + mod='models', operation_group='deny_assignments') + DenyAssignmentPermission = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPermission', + mod='models', operation_group='deny_assignments') + DenyAssignmentPrincipal = get_sdk(cli_ctx, ResourceType.MGMT_AUTHORIZATION, 'DenyAssignmentPrincipal', + mod='models', operation_group='deny_assignments') + + 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')] + + 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)) + + return DenyAssignment(properties=DenyAssignmentProperties( + deny_assignment_name=deny_assignment_name, + description=description or '', + 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, + )) + + +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: + # 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): subscription_scope = '/subscriptions/' + subscription_id if scope: @@ -576,7 +913,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/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..da4d48da119 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/role/tests/latest/test_deny_assignment.py @@ -0,0 +1,230 @@ +# -------------------------------------------------------------------------------------------- +# 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 knack.util import CLIError + +from azure.cli.testsdk import LiveScenarioTest + + +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.""" + 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 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 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): + """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 (not in recordings) + """ + + 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 Everyone', + 'action': 'Microsoft.Authorization/roleAssignments/write', + 'exclude_id': self.create_guid() + }) + + # Create in Everyone mode (no --principal-id) + 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 - Everyone mode"', + 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_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): + 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_everyone_mode(self): + """Should fail if no excluded principals are provided in Everyone mode.""" + 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' + ) + + 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' + ) 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: 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 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 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 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',