diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 0c4583c078b..8c35ac6b9ab 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -11,11 +11,12 @@ from azure.cli.core._session import ACCOUNT from azure.cli.core.azclierror import AuthenticationError from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription +from azure.cli.core.util import in_cloud_console, can_launch_browser, assert_guid, is_github_codespaces from azure.cli.core.auth.credential_adaptor import CredentialAdaptor -from azure.cli.core.util import in_cloud_console, can_launch_browser, is_github_codespaces from knack.log import get_logger from knack.util import CLIError + logger = get_logger(__name__) # Names below are used by azure-xplat-cli to persist account information into @@ -40,7 +41,6 @@ _MANAGED_BY_TENANTS = 'managedByTenants' _USER_ENTITY = 'user' _USER_NAME = 'name' -_CLIENT_ID = 'clientId' _CLOUD_SHELL_ID = 'cloudShellID' _SUBSCRIPTIONS = 'subscriptions' _INSTALLATION_ID = 'installationId' @@ -54,6 +54,7 @@ _TOKEN_ENTRY_TOKEN_TYPE = 'tokenType' _TENANT_LEVEL_ACCOUNT_NAME = 'N/A(tenant level account)' +_ENVIRONMENT_VARIABLE_ACCOUNT_NAME = 'Environment Variable Subscription' _SYSTEM_ASSIGNED_IDENTITY = 'systemAssignedIdentity' _USER_ASSIGNED_IDENTITY = 'userAssignedIdentity' @@ -61,6 +62,15 @@ _AZ_LOGIN_MESSAGE = "Please run 'az login' to setup account." +_TENANT = 'tenant' +_CLIENT_ID = 'client_id' +_CLIENT_SECRET = 'client_secret' + +_AZURE_CLIENT_ID = 'AZURE_CLIENT_ID' +_AZURE_CLIENT_SECRET = 'AZURE_CLIENT_SECRET' +_AZURE_TENANT_ID = 'AZURE_TENANT_ID' +_AZURE_SUBSCRIPTION_ID = 'AZURE_SUBSCRIPTION_ID' + def load_subscriptions(cli_ctx, all_clouds=False, refresh=False): profile = Profile(cli_ctx=cli_ctx) @@ -70,6 +80,52 @@ def load_subscriptions(cli_ctx, all_clouds=False, refresh=False): return subscriptions +def env_var_auth_configured(): + keys = [_AZURE_CLIENT_ID, _AZURE_CLIENT_SECRET, _AZURE_TENANT_ID] + all_provided = all(key in os.environ for key in keys) + + if all_provided: + return True + + any_provided = any(key in os.environ for key in keys) + if any_provided: + raise CLIError("To authenticate using environment variables, " + "all of {}, {}, {} must be specified.".format(*keys)) + + return False + + +def load_env_var_credential(): + if env_var_auth_configured(): + credential = { + _CLIENT_ID: os.environ[_AZURE_CLIENT_ID], + _TENANT: os.environ[_AZURE_TENANT_ID], + _CLIENT_SECRET: os.environ[_AZURE_CLIENT_SECRET] + } + return credential + return None + + +def load_env_var_subscription(): + if env_var_auth_configured(): + env_var_subscription = { + _SUBSCRIPTION_ID: None, + _SUBSCRIPTION_NAME: _ENVIRONMENT_VARIABLE_ACCOUNT_NAME, + _TENANT_ID: os.environ[_AZURE_TENANT_ID], + _USER: { + _USER_NAME: os.environ[_AZURE_CLIENT_ID], + _USER_TYPE: _SERVICE_PRINCIPAL + } + } + + if _AZURE_SUBSCRIPTION_ID in os.environ: + subscription_id = os.environ[_AZURE_SUBSCRIPTION_ID] + assert_guid(subscription_id, _AZURE_SUBSCRIPTION_ID) + env_var_subscription[_SUBSCRIPTION_ID] = subscription_id + return env_var_subscription + return None + + def _detect_adfs_authority(authority_url, tenant): """Prepare authority and tenant for Azure Identity with ADFS support. If `authority_url` ends with '/adfs', `tenant` will be set to 'adfs'. For example: @@ -356,7 +412,7 @@ def get_raw_token(self, resource=None, scopes=None, subscription=None, tenant=No if subscription and tenant: raise CLIError("Please specify only one of subscription and tenant, not both") - account = self.get_subscription(subscription) + account = self.get_subscription(subscription, allow_null_subscription=True) managed_identity_type, managed_identity_id = Profile._parse_managed_identity_account(account) @@ -540,24 +596,42 @@ def get_current_account_user(self): return active_account[_USER_ENTITY][_USER_NAME] - def get_subscription(self, subscription=None): # take id or name + def get_subscription(self, subscription=None, allow_null_subscription=False): # take id or name + # Attempt to use env vars + if env_var_auth_configured(): + logger.debug("Using subscription configured in environment variables.") + env_var_sub = load_env_var_subscription() + if subscription: + # Subscription ID must be a GUID + assert_guid(subscription, _AZURE_SUBSCRIPTION_ID) + # Overwrite env var subscription if given as argument to get_subscription() + env_var_sub[_SUBSCRIPTION_ID] = subscription + + if not env_var_sub[_SUBSCRIPTION_ID] and not allow_null_subscription: + error = """Subscription is undefined. + Please specific the subscription ID with either {} or --subscription.""" + raise CLIError(error.format(_AZURE_SUBSCRIPTION_ID)) + return env_var_sub + + # Attempt to use cached subscriptions subscriptions = self.load_cached_subscriptions() - if not subscriptions: - raise CLIError(_AZ_LOGIN_MESSAGE) - - result = [x for x in subscriptions if ( - not subscription and x.get(_IS_DEFAULT_SUBSCRIPTION) or - subscription and subscription.lower() in [x[_SUBSCRIPTION_ID].lower(), x[ - _SUBSCRIPTION_NAME].lower()])] - if not result and subscription: - raise CLIError("Subscription '{}' not found. " - "Check the spelling and casing and try again.".format(subscription)) - if not result and not subscription: - raise CLIError("No subscription found. Run 'az account set' to select a subscription.") - if len(result) > 1: - raise CLIError("Multiple subscriptions with the name '{}' found. " - "Specify the subscription ID.".format(subscription)) - return result[0] + + if subscriptions: + result = [x for x in subscriptions if ( + not subscription and x.get(_IS_DEFAULT_SUBSCRIPTION) or + subscription and subscription.lower() in [x[_SUBSCRIPTION_ID].lower(), x[ + _SUBSCRIPTION_NAME].lower()])] + if not result and subscription: + raise CLIError("Subscription '{}' not found. " + "Check the spelling and casing and try again.".format(subscription)) + if not result and not subscription: + raise CLIError("No subscription found. Run 'az account set' to select a subscription.") + if len(result) > 1: + raise CLIError("Multiple subscriptions with the name '{}' found. " + "Specify the subscription ID.".format(subscription)) + return result[0] + + raise CLIError(_AZ_LOGIN_MESSAGE) def get_subscription_id(self, subscription=None): # take id or name return self.get_subscription(subscription)[_SUBSCRIPTION_ID] diff --git a/src/azure-cli-core/azure/cli/core/auth/identity.py b/src/azure-cli-core/azure/cli/core/auth/identity.py index 6f86bee804c..9ff3f77a581 100644 --- a/src/azure-cli-core/azure/cli/core/auth/identity.py +++ b/src/azure-cli-core/azure/cli/core/auth/identity.py @@ -361,7 +361,14 @@ def __init__(self, secret_store): self._entries = [] def load_entry(self, sp_id, tenant): + from azure.cli.core._profile import env_var_auth_configured, load_env_var_credential self._load_persistence() + + # If no login data, look for service principal credential in environment variables + if not self._entries and env_var_auth_configured(): + logger.debug("Using service principal credential configured in environment variables.") + self._entries = [load_env_var_credential()] + matched = [x for x in self._entries if sp_id == x[_CLIENT_ID]] if not matched: raise CLIError("Could not retrieve credential from local cache for service principal {}. " diff --git a/src/azure-cli-core/azure/cli/core/cloud.py b/src/azure-cli-core/azure/cli/core/cloud.py index ac0d909bd16..8b247df1436 100644 --- a/src/azure-cli-core/azure/cli/core/cloud.py +++ b/src/azure-cli-core/azure/cli/core/cloud.py @@ -681,7 +681,8 @@ def set_cloud_subscription(cli_ctx, cloud_name, subscription): def _set_active_subscription(cli_ctx, cloud_name): from azure.cli.core._profile import (Profile, _ENVIRONMENT_NAME, _SUBSCRIPTION_ID, - _STATE, _SUBSCRIPTION_NAME) + _STATE, _SUBSCRIPTION_NAME, _AZURE_SUBSCRIPTION_ID, + env_var_auth_configured, load_env_var_subscription) profile = Profile(cli_ctx=cli_ctx) subscription_to_use = get_cloud_subscription(cloud_name) or \ next((s[_SUBSCRIPTION_ID] for s in profile.load_cached_subscriptions() # noqa @@ -697,6 +698,8 @@ def _set_active_subscription(cli_ctx, cloud_name): logger.warning(e) logger.warning("Unable to automatically switch the active subscription. " "Use 'az account set'.") + elif env_var_auth_configured(): + logger.warning("Using subscription %s configured by environment variable %s. ", load_env_var_subscription(), [_SUBSCRIPTION_ID]) else: logger.warning("Use 'az login' to log in to this cloud.") logger.warning("Use 'az account set' to set the active subscription.") 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 0769e4759dc..c548476d2bc 100644 --- a/src/azure-cli-core/azure/cli/core/commands/arm.py +++ b/src/azure-cli-core/azure/cli/core/commands/arm.py @@ -366,7 +366,8 @@ def __call__(self, parser, namespace, value, option_string=None): sub_id = sub['id'] break if not sub_id: - logger.warning("Subscription '%s' not recognized.", value) + # User may be authenticating via environment variables + logger.debug("Subscription '%s' not found in local cache.", value) sub_id = value namespace._subscription = sub_id # pylint: disable=protected-access diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 54b7ce7e490..dc5b9c015fc 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -17,7 +17,7 @@ (get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string, open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request, should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent, - _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd) + _get_parent_proc_name, is_wsl, is_guid, assert_guid, run_cmd, run_az_cmd) from azure.cli.core.mock import DummyCli @@ -422,6 +422,18 @@ def test_get_parent_proc_name(self, mock_process_type): parent2.name.return_value = "bash" self.assertEqual(_get_parent_proc_name(), "pwsh") + def test_guid(self): + self.assertTrue(is_guid("201ea53e-07b9-4ebf-a85e-5482e48e835c")) + self.assertFalse(is_guid("")) + self.assertFalse(is_guid(None)) + self.assertFalse(is_guid("foo")) + + from knack.util import CLIError + assert_guid("201ea53e-07b9-4ebf-a85e-5482e48e835c") + assert_guid("201ea53e-07b9-4ebf-a85e-5482e48e835c", "named_guid") + self.assertRaisesRegex(CLIError, "named_guid must be a GUID.", assert_guid, "foo", "named_guid") + self.assertRaisesRegex(CLIError, "foo is not a GUID.", assert_guid, "foo") + def test_cli_run_cmd(self): cmd = ["echo", "abc"] if platform.system().lower() == "windows": diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index e4c319ad55f..9bc4af5f475 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -1277,10 +1277,17 @@ def is_guid(guid): try: uuid.UUID(guid) return True - except ValueError: + except (ValueError, TypeError): return False +def assert_guid(guid, name=None): + if not is_guid(guid): + if name: + raise CLIError("{} must be a GUID.".format(name)) + raise CLIError("{} is not a GUID.".format(guid)) + + def handle_version_update(): """Clean up information in local files that may be invalidated because of a version update of Azure CLI diff --git a/src/azure-cli/azure/cli/command_modules/profile/custom.py b/src/azure-cli/azure/cli/command_modules/profile/custom.py index 6a148d2e52b..8970f8a4838 100644 --- a/src/azure-cli/azure/cli/command_modules/profile/custom.py +++ b/src/azure-cli/azure/cli/command_modules/profile/custom.py @@ -56,24 +56,36 @@ def list_subscriptions(cmd, all=False, refresh=False): # pylint: disable=redefined-builtin """List the imported subscriptions.""" from azure.cli.core.api import load_subscriptions + from azure.cli.core._profile import load_env_var_subscription, env_var_auth_configured + + # Attempt to fetch subscription configured in environment variables + if env_var_auth_configured(): + logger.warning("Fetching subscription configured in environment variables.") + subscriptions = [load_env_var_subscription()] + return subscriptions subscriptions = load_subscriptions(cmd.cli_ctx, all_clouds=all, refresh=refresh) + + if subscriptions: + for sub in subscriptions: + sub['cloudName'] = sub.pop('environmentName', None) + if not all: + enabled_ones = [s for s in subscriptions if s.get('state') == 'Enabled'] + if len(enabled_ones) != len(subscriptions): + logger.warning("A few accounts are skipped as they don't have 'Enabled' state. " + "Use '--all' to display them.") + subscriptions = enabled_ones + return subscriptions + + if not subscriptions: logger.warning('Please run "az login" to access your accounts.') - for sub in subscriptions: - sub['cloudName'] = sub.pop('environmentName', None) - if not all: - enabled_ones = [s for s in subscriptions if s.get('state') == 'Enabled'] - if len(enabled_ones) != len(subscriptions): - logger.warning("A few accounts are skipped as they don't have 'Enabled' state. " - "Use '--all' to display them.") - subscriptions = enabled_ones - return subscriptions + return [] def show_subscription(cmd, subscription=None): profile = Profile(cli_ctx=cmd.cli_ctx) - return profile.get_subscription(subscription) + return profile.get_subscription(subscription, allow_null_subscription=True) def get_access_token(cmd, subscription=None, resource=None, scopes=None, resource_type=None, tenant=None):