diff --git a/src/azure-cli/azure/cli/command_modules/identity/__init__.py b/src/azure-cli/azure/cli/command_modules/identity/__init__.py index 63f5d8f56a4..beac0422253 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/identity/__init__.py @@ -7,15 +7,28 @@ from azure.cli.core import AzCommandsLoader from azure.cli.core.profiles import ResourceType - class IdentityCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): from azure.cli.core.commands import CliCommandType - identity_custom = CliCommandType(operations_tmpl='azure.cli.command_modules.identity.custom#{}') + from azure.cli.command_modules.identity._client_factory import (_msi_user_identities_operations, + _msi_federated_identity_credentials_operations) + + # Base identity commands + identity_custom = CliCommandType( + operations_tmpl='azure.cli.command_modules.identity.custom#{}', + client_factory=_msi_user_identities_operations + ) + + # Federated credential commands + federated_identity_custom = CliCommandType( + operations_tmpl='azure.cli.command_modules.identity.custom#{}', + client_factory=_msi_federated_identity_credentials_operations + ) + super().__init__(cli_ctx=cli_ctx, - resource_type=ResourceType.MGMT_MSI, - custom_command_type=identity_custom) + resource_type=ResourceType.MGMT_MSI, + custom_command_type=identity_custom) def load_command_table(self, args): from azure.cli.command_modules.identity.commands import load_command_table @@ -26,5 +39,4 @@ def load_arguments(self, command): from azure.cli.command_modules.identity._params import load_arguments load_arguments(self, command) - COMMAND_LOADER_CLS = IdentityCommandsLoader diff --git a/src/azure-cli/azure/cli/command_modules/identity/_client_factory.py b/src/azure-cli/azure/cli/command_modules/identity/_client_factory.py index a549775369d..ee79c7555f0 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/identity/_client_factory.py @@ -26,5 +26,9 @@ def _msi_operations_operations(cli_ctx, _): return _msi_client_factory(cli_ctx).operations -def _msi_federated_identity_credentials_operations(cli_ctx, _): - return _msi_client_factory(cli_ctx).federated_identity_credentials +def _msi_federated_identity_credentials_operations(cli_ctx, **_): + """ + api version is specified for federated identity credentials command because new api version (2023-01-31) of MSI does not support + flexible fic command. In order to avoid a breaking change, multi-api package is used. + """ + return _msi_client_factory(cli_ctx, api_version='2025-01-31-PREVIEW').federated_identity_credentials diff --git a/src/azure-cli/azure/cli/command_modules/identity/_help.py b/src/azure-cli/azure/cli/command_modules/identity/_help.py index 44949e01792..d76cc4d25e7 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/_help.py +++ b/src/azure-cli/azure/cli/command_modules/identity/_help.py @@ -3,8 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -# pylint: disable=line-too-long, too-many-lines - from knack.help_files import helps helps['identity'] = """ @@ -14,7 +12,7 @@ helps['identity create'] = """ type: command -short-summary: Create Identities. +short-summary: Create an identity. examples: - name: Create an identity. text: | @@ -36,52 +34,71 @@ short-summary: List the associated resources for the identity. """ +helps['identity show'] = """ +type: command +short-summary: Show the details of a managed identity. +""" + +helps['identity delete'] = """ +type: command +short-summary: Delete a managed identity. +""" + helps['identity federated-credential'] = """ type: group -short-summary: Manage federated identity credentials under user assigned identities. +short-summary: [Preview] Manage federated credentials under managed identities. """ helps['identity federated-credential create'] = """ type: command -short-summary: Create a federated identity credential under an existing user assigned identity. +short-summary: [Preview] Create a federated credential. +parameters: + - name: --name -n + type: string + short-summary: Name of the federated credential. + long-summary: Must start with a letter or number, and can contain letters, numbers, underscores, and hyphens. Length must be between 3-120 characters. + - name: --identity-name + type: string + short-summary: Name of the managed identity. + - name: --issuer + type: string + short-summary: The URL of the issuer to be trusted. + long-summary: For GitHub Actions, use 'https://token.actions.githubusercontent.com' + - name: --subject + type: string + short-summary: The identifier of the external identity. + long-summary: Cannot be used with claims-matching-expression-* parameters. + - name: --audiences + type: array + short-summary: List of audiences that can appear in the issued token. examples: - - name: Create a federated identity credential under a specific user assigned identity. + - name: Create a federated identity credential with subject matching text: | - az identity federated-credential create --name myFicName --identity-name myIdentityName --resource-group myResourceGroup --issuer myIssuer --subject mySubject --audiences myAudiences + az identity federated-credential create -g MyResourceGroup --identity-name MyIdentity -n MyFicName \\ + --issuer https://token.actions.githubusercontent.com \\ + --subject "system:serviceaccount:ns:svcaccount" \\ + --audiences api://AzureADTokenExchange """ helps['identity federated-credential update'] = """ type: command -short-summary: Update a federated identity credential under an existing user assigned identity. -examples: - - name: Update a federated identity credential under a specific user assigned identity. - text: | - az identity federated-credential update --name myFicName --identity-name myIdentityName --resource-group myResourceGroup --issuer myIssuer --subject mySubject --audiences myAudiences +short-summary: [Preview] Update a federated credential. """ helps['identity federated-credential delete'] = """ type: command -short-summary: Delete a federated identity credential under an existing user assigned identity. +short-summary: [Preview] Delete a federated credential. examples: - - name: Delete a federated identity credential under a specific user assigned identity. - text: | - az identity federated-credential delete --name myFicName --identity-name myIdentityName --resource-group myResourceGroup + - name: Delete a federated credential + text: az identity federated-credential delete -g MyResourceGroup --identity-name MyIdentity -n MyFicName """ helps['identity federated-credential show'] = """ type: command -short-summary: Show a federated identity credential under an existing user assigned identity. -examples: - - name: Show a federated identity credential under a specific user assigned identity. - text: | - az identity federated-credential show --name myFicName --identity-name myIdentityName --resource-group myResourceGroup +short-summary: [Preview] Show details of a federated credential. """ helps['identity federated-credential list'] = """ type: command -short-summary: List all federated identity credentials under an existing user assigned identity. -examples: - - name: List all federated identity credentials under an existing user assigned identity. - text: | - az identity federated-credential list --identity-name myIdentityName --resource-group myResourceGroup +short-summary: [Preview] List all federated credentials for a managed identity. """ diff --git a/src/azure-cli/azure/cli/command_modules/identity/_params.py b/src/azure-cli/azure/cli/command_modules/identity/_params.py index 9ff9aaee2bc..c5ed3bee478 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/_params.py +++ b/src/azure-cli/azure/cli/command_modules/identity/_params.py @@ -3,18 +3,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -# pylint: disable=line-too-long, too-many-lines from knack.arguments import CLIArgumentType - from azure.cli.core.commands.parameters import get_location_type, tags_type - name_arg_type = CLIArgumentType(options_list=('--name', '-n'), metavar='NAME', - help='The name of the identity resource.') - + help='The name of the identity resource.') def load_arguments(self, _): - + # Base identity parameters with self.argument_context('identity') as c: c.argument('resource_name', arg_type=name_arg_type, id_part='name') @@ -22,12 +18,43 @@ def load_arguments(self, _): c.argument('location', get_location_type(self.cli_ctx), required=False) c.argument('tags', tags_type) - with self.argument_context('identity federated-credential', min_api='2022-01-31-preview') as c: - c.argument('federated_credential_name', options_list=('--name', '-n'), help='The name of the federated identity credential resource.') - c.argument('identity_name', help='The name of the identity resource.') - - for scope in ['identity federated-credential create', 'identity federated-credential update']: - with self.argument_context(scope) as c: - c.argument('issuer', help='The openId connect metadata URL of the issuer of the identity provider that Azure AD would use in the token exchange protocol for validating tokens before issuing a token as the user-assigned managed identity.') - c.argument('subject', help='The sub value in the token sent to Azure AD for getting the user-assigned managed identity token. The value configured in the federated credential and the one in the incoming token must exactly match for Azure AD to issue the access token.') - c.argument('audiences', nargs='+', help='The aud value in the token sent to Azure for getting the user-assigned managed identity token. The value configured in the federated credential and the one in the incoming token must exactly match for Azure to issue the access token.') + # Register federated-credential parameters as part of the identity group + with self.argument_context('identity federated-credential', is_preview=True) as c: + c.argument('federated_credential_name', options_list=('--name', '-n'), + help='[Preview] The name of the federated identity credential resource. Must start with a letter, number and can contain letters, numbers, underscores, and hyphens. Length must be between 3-120 characters.', + type=str) + c.argument('identity_name', + help='[Preview] The name of the user assigned identity.') + + # Register create/update specific parameters + with self.argument_context('identity federated-credential create', is_preview=True) as c: + c.argument('issuer', + help='[Preview] The URL of the issuer to be trusted.', + required=True) + c.argument('subject', + help='[Preview] The identifier of the external identity. Cannot be used with claims-matching-expression-*.') + c.argument('audiences', + nargs='+', + help='[Preview] The list of audiences that can appear in the issued token.', + required=True) + c.argument('claims_matching_expression_value', + help='[Preview] The wildcard-based expression for matching incoming subject claims. Cannot be used with subject.') + c.argument('claims_matching_expression_version', + type=int, + help='[Preview] The version of the claims matching expression language.') + + with self.argument_context('identity federated-credential update', is_preview=True) as c: + c.argument('issuer', + help='[Preview] The URL of the issuer to be trusted.', + required=True) + c.argument('subject', + help='[Preview] The identifier of the external identity. Cannot be used with claims-matching-expression-*.') + c.argument('audiences', + nargs='+', + help='[Preview] The list of audiences that can appear in the issued token.', + required=True) + c.argument('claims_matching_expression_value', + help='[Preview] The wildcard-based expression for matching incoming subject claims. Cannot be used with subject.') + c.argument('claims_matching_expression_version', + type=int, + help='[Preview] The version of the claims matching expression language.') diff --git a/src/azure-cli/azure/cli/command_modules/identity/commands.py b/src/azure-cli/azure/cli/command_modules/identity/commands.py index d43da6df438..a370294e127 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/commands.py +++ b/src/azure-cli/azure/cli/command_modules/identity/commands.py @@ -3,45 +3,30 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- - from azure.cli.core.commands import CliCommandType -from ._client_factory import _msi_user_identities_operations, _msi_operations_operations, \ - _msi_federated_identity_credentials_operations - +from ._client_factory import (_msi_user_identities_operations, + _msi_operations_operations, + _msi_federated_identity_credentials_operations) from ._validators import process_msi_namespace - def load_command_table(self, _): - - identity_sdk = CliCommandType( - operations_tmpl='azure.mgmt.msi.operations#UserAssignedIdentitiesOperations.{}', - client_factory=_msi_user_identities_operations - ) - msi_operations_sdk = CliCommandType( - operations_tmpl='azure.mgmt.msi.operations#Operations.{}', - client_factory=_msi_operations_operations - ) - federated_identity_credentials_sdk = CliCommandType( - operations_tmpl='azure.mgmt.msi.operations#FederatedIdentityCredentialsOperations.{}', - client_factory=_msi_federated_identity_credentials_operations - ) - - with self.command_group('identity', identity_sdk, client_factory=_msi_user_identities_operations) as g: + # Base operations + with self.command_group('identity') as g: g.custom_command('create', 'create_identity', validator=process_msi_namespace) - g.show_command('show', 'get') - g.command('delete', 'delete') + g.command('show', 'get', operations_tmpl='azure.mgmt.msi.operations#UserAssignedIdentitiesOperations.{}') + g.command('delete', 'delete', operations_tmpl='azure.mgmt.msi.operations#UserAssignedIdentitiesOperations.{}') g.custom_command('list', 'list_user_assigned_identities') g.custom_command('list-resources', 'list_identity_resources', min_api='2021-09-30-preview') + g.command('list-operations', 'list', operations_tmpl='azure.mgmt.msi.operations#Operations.{}') - with self.command_group('identity', msi_operations_sdk, client_factory=_msi_operations_operations) as g: - g.command('list-operations', 'list') - - with self.command_group('identity federated-credential', federated_identity_credentials_sdk, - client_factory=_msi_federated_identity_credentials_operations, - min_api='2022-01-31-preview') as g: + # Federated credential operations + with self.command_group('identity federated-credential', + operations_tmpl='azure.mgmt.msi.operations#FederatedIdentityCredentialsOperations.{}', + client_factory=_msi_federated_identity_credentials_operations, + is_preview=True) as g: g.custom_command('create', 'create_or_update_federated_credential') g.custom_command('update', 'create_or_update_federated_credential') - g.custom_show_command('show', 'show_federated_credential') g.custom_command('delete', 'delete_federated_credential', confirmation=True) + g.custom_command('show', 'show_federated_credential') g.custom_command('list', 'list_federated_credential') diff --git a/src/azure-cli/azure/cli/command_modules/identity/custom.py b/src/azure-cli/azure/cli/command_modules/identity/custom.py index c1b80cb8848..6be2a6e7d1a 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/custom.py +++ b/src/azure-cli/azure/cli/command_modules/identity/custom.py @@ -5,10 +5,10 @@ from azure.cli.core.profiles import ResourceType from azure.cli.core.azclierror import ( - RequiredArgumentMissingError + RequiredArgumentMissingError, + MutuallyExclusiveArgumentError ) - def list_user_assigned_identities(cmd, resource_group_name=None): from azure.cli.command_modules.identity._client_factory import _msi_client_factory client = _msi_client_factory(cmd.cli_ctx) @@ -16,49 +16,75 @@ def list_user_assigned_identities(cmd, resource_group_name=None): return client.user_assigned_identities.list_by_resource_group(resource_group_name) return client.user_assigned_identities.list_by_subscription() - def create_identity(client, resource_group_name, resource_name, location, tags=None): parameters = {} parameters['location'] = location if tags is not None: parameters['tags'] = tags return client.create_or_update(resource_group_name=resource_group_name, - resource_name=resource_name, - parameters=parameters) - + resource_name=resource_name, + parameters=parameters) def list_identity_resources(cmd, resource_group_name, resource_name): from azure.cli.command_modules.identity._client_factory import _msi_list_resources_client client = _msi_list_resources_client(cmd.cli_ctx) return client.list_associated_resources(resource_group_name=resource_group_name, - resource_name=resource_name) - + resource_name=resource_name) def create_or_update_federated_credential(cmd, client, resource_group_name, identity_name, federated_credential_name, - issuer=None, subject=None, audiences=None): + issuer=None, subject=None, audiences=None, claims_matching_expression_value=None, + claims_matching_expression_version=None): _default_audiences = ['api://AzureADTokenExchange'] audiences = _default_audiences if not audiences else audiences - if not issuer or not subject: - raise RequiredArgumentMissingError('usage error: please provide both --issuer and --subject parameters') - - FederatedIdentityCredential = cmd.get_models('FederatedIdentityCredential', resource_type=ResourceType.MGMT_MSI, - operation_group='federated_identity_credentials') - parameters = FederatedIdentityCredential(issuer=issuer, subject=subject, audiences=audiences) - - return client.create_or_update(resource_group_name=resource_group_name, resource_name=identity_name, - federated_identity_credential_resource_name=federated_credential_name, - parameters=parameters) + if not issuer: + recommendation = ("Please provide the issuer URL using --issuer parameter. " + "For GitHub Actions, use 'https://token.actions.githubusercontent.com'") + raise RequiredArgumentMissingError('Issuer URL is required for federated credential creation', recommendation) + + # When subject is present, CME should be null + if subject: + claims_matching_expression_value = None + claims_matching_expression_version = None + elif not claims_matching_expression_value: + recommendation = [ + "Option 1: Provide --subject to create a federated credential with direct subject matching", + ("Option 2: Provide --claims-matching-expression-value and --claims-matching-expression-version " + "to create a federated credential with claims matching expression") + ] + raise RequiredArgumentMissingError('Missing required authentication criteria for federated credential', + recommendation) + + if subject and claims_matching_expression_value: + recommendation = [ + "Option 1: Remove --subject to use claims matching expression", + "Option 2: Remove --claims-matching-expression-value and --claims-matching-expression-version to use subject" + ] + raise MutuallyExclusiveArgumentError('Subject and claims matching expression cannot be used together', + recommendation) + + FederatedIdentityCredential = cmd.get_models('FederatedIdentityCredential', + resource_type=ResourceType.MGMT_MSI, + operation_group='federated_identity_credentials') + parameters = FederatedIdentityCredential(issuer=issuer, + subject=subject, + audiences=audiences, + claims_matching_expression_value=claims_matching_expression_value, + claims_matching_expression_version=claims_matching_expression_version) + return client.create_or_update(resource_group_name=resource_group_name, + resource_name=identity_name, + federated_identity_credential_resource_name=federated_credential_name, + parameters=parameters) def delete_federated_credential(client, resource_group_name, identity_name, federated_credential_name): - return client.delete(resource_group_name=resource_group_name, resource_name=identity_name, - federated_identity_credential_resource_name=federated_credential_name) - + return client.delete(resource_group_name=resource_group_name, + resource_name=identity_name, + federated_identity_credential_resource_name=federated_credential_name) def show_federated_credential(client, resource_group_name, identity_name, federated_credential_name): - return client.get(resource_group_name=resource_group_name, resource_name=identity_name, - federated_identity_credential_resource_name=federated_credential_name) - + return client.get(resource_group_name=resource_group_name, + resource_name=identity_name, + federated_identity_credential_resource_name=federated_credential_name) def list_federated_credential(client, resource_group_name, identity_name): return client.list(resource_group_name=resource_group_name, resource_name=identity_name) diff --git a/src/azure-cli/azure/cli/command_modules/identity/tests/latest/test_identity.py b/src/azure-cli/azure/cli/command_modules/identity/tests/latest/test_identity.py index 9367a106f03..392e796ce33 100644 --- a/src/azure-cli/azure/cli/command_modules/identity/tests/latest/test_identity.py +++ b/src/azure-cli/azure/cli/command_modules/identity/tests/latest/test_identity.py @@ -88,12 +88,14 @@ def test_federated_identity_credential(self, resource_group): self.check('[1].subject', '{subject2}'), ]) - # update a federated identity credential + # update a federated identity credential with subject (should clear CME) self.cmd('identity federated-credential update --name {fic1} --identity-name {identity} --resource-group {rg} ' '--subject {subject3} --issuer {issuer} --audiences {audience}', checks=[ self.check('name', '{fic1}'), - self.check('subject', '{subject3}') + self.check('subject', '{subject3}'), + self.check('claimsMatchingExpressionValue', None), + self.check('claimsMatchingExpressionVersion', None) ]) # delete a federated identity credential @@ -118,3 +120,66 @@ def test_federated_identity_credential(self, resource_group): self.check('type(@)', 'array'), self.check('length(@)', 0) ]) + + @ResourceGroupPreparer(name_prefix='cli_test_federated_identity_credential_cme_', location='eastus2euap') + def test_federated_identity_credential_cme(self, resource_group): + self.kwargs.update({ + 'identity': 'ide', + 'fic': 'fic1', + 'issuer': 'https://tokens.githubusercontent.com', + 'subject': None, + 'audience': 'api://AzureADTokenExchange', + 'expression_value': "claims['sub'] startswith 'repo:contoso-org/contoso-repo:ref:refs/heads'", + 'expression_version': '1', + 'new_expression_value': "claims['sub'] startswith 'repo:contoso-org/contoso-repo:environment:Production'" + }) + + self.cmd('identity create -n {identity} -g {rg}') + + # create a federated identity credential with CME + self.cmd('identity federated-credential create --name {fic} --identity-name {identity} --resource-group {rg} ' + '--issuer {issuer} --audiences {audience} ' + '--claims-matching-expression-value "{expression_value}" ' + '--claims-matching-expression-version {expression_version}', + checks=[ + self.check('length(audiences)', 1), + self.check('audiences[0]', '{audience}'), + self.check('issuer', '{issuer}'), + self.check('subject', None), + self.check('claimsMatchingExpressionValue', '{expression_value}'), + self.check('claimsMatchingExpressionVersion', '{expression_version}') + ]) + + # show the federated identity credential with CME + self.cmd('identity federated-credential show --name {fic} --identity-name {identity} --resource-group {rg}', + checks=[ + self.check('length(audiences)', 1), + self.check('audiences[0]', '{audience}'), + self.check('issuer', '{issuer}'), + self.check('subject', None), + self.check('claimsMatchingExpressionValue', '{expression_value}'), + self.check('claimsMatchingExpressionVersion', '{expression_version}') + ]) + + # update federated identity credential with new CME + self.cmd('identity federated-credential update --name {fic} --identity-name {identity} --resource-group {rg} ' + '--claims-matching-expression-value "{new_expression_value}"', + checks=[ + self.check('name', '{fic}'), + self.check('claimsMatchingExpressionValue', '{new_expression_value}'), + self.check('claimsMatchingExpressionVersion', '{expression_version}') + ]) + + # list the federated identity credentials and verify CME + self.cmd('identity federated-credential list --identity-name {identity} --resource-group {rg}', + checks=[ + self.check('type(@)', 'array'), + self.check('length(@)', 1), + self.check('[0].name', '{fic}'), + self.check('[0].claimsMatchingExpressionValue', '{new_expression_value}'), + self.check('[0].claimsMatchingExpressionVersion', '{expression_version}') + ]) + + # cleanup + self.cmd('identity federated-credential delete --name {fic} ' + '--identity-name {identity} --resource-group {rg} --yes') \ No newline at end of file