diff --git a/azure-cli.pyproj b/azure-cli.pyproj index 7a7dcf8216a..0744e604dd2 100644 --- a/azure-cli.pyproj +++ b/azure-cli.pyproj @@ -45,6 +45,7 @@ + @@ -54,6 +55,7 @@ + diff --git a/requirements.txt b/requirements.txt index 0f8de54df5d..5a1ec7e75d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,5 @@ requests==2.9.1 six==1.10.0 vcrpy==1.7.4 -#Same as: -e git+https://github.com/yugangw-msft/azure-activedirectory-library-for-python.git@0.2.0#egg=azure-activedirectory-library-for-python -http://40.112.211.51:8080/packages/adal-0.2.0.zip +#-e git+https://github.com/yugangw-msft/azure-activedirectory-library-for-python@v2.1#egg=azure-activedirectory-library-for-python +http://40.112.211.51:8080/packages/adal-0.2.1.zip diff --git a/scripts/command_modules/install.py b/scripts/command_modules/install.py index a683b2c4120..cf2a74218b4 100644 --- a/scripts/command_modules/install.py +++ b/scripts/command_modules/install.py @@ -4,14 +4,12 @@ from _common import get_all_command_modules, exec_command, print_summary -dev_null_file = open(os.devnull, 'w') - all_command_modules = get_all_command_modules() print("Installing command modules.") failed_module_names = [] for name, fullpath in all_command_modules: - success = exec_command("pip install -e "+fullpath, stdout=dev_null_file) + success = exec_command("pip install -e "+fullpath) if not success: failed_module_names.append(name) diff --git a/setup.py b/setup.py index 5dc58d49a47..ebcb9c01ea8 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ ] DEPENDENCIES = [ + 'adal==0.2.1', #from internal index server. 'applicationinsights', 'argcomplete', 'azure==2.0.0rc1', @@ -81,15 +82,15 @@ def _post_install(dir): # Upgrade/update will install if it doesn't exist. # We do this so these components are updated when the user updates the CLI. if INSTALL_FROM_PUBLIC: - pip.main(['install', '--upgrade', 'azure-cli-components', '--disable-pip-version-check']) - check_call(['az', 'components', 'update', '-n', 'profile', '--disable-version-check']) + pip.main(['install', '--upgrade', 'azure-cli-component', '--disable-pip-version-check']) + check_call(['az', 'component', 'update', '-n', 'profile']) else: # use private PyPI server. - pip.main(['install', '--upgrade', 'azure-cli-components', '--extra-index-url', + pip.main(['install', '--upgrade', 'azure-cli-component', '--extra-index-url', PRIVATE_PYPI_URL, '--trusted-host', PRIVATE_PYPI_HOST, '--disable-pip-version-check']) - check_call(['az', 'components', 'update', '-n', 'profile', '-p', '--disable-version-check']) + check_call(['az', 'component', 'update', '-n', 'profile', '-p']) class OnInstall(install): def run(self): diff --git a/src/azure/cli/_azure_env.py b/src/azure/cli/_azure_env.py new file mode 100644 index 00000000000..2aad9a54b91 --- /dev/null +++ b/src/azure/cli/_azure_env.py @@ -0,0 +1,43 @@ +CLIENT_ID = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' + +ENV_DEFAULT = 'AzureCloud' +ENV_US_GOVERNMENT = 'AzureUSGovernment' +ENV_CHINA = 'AzureChinaCloud' + +COMMON_TENANT = 'common' + +#ported from https://github.com/Azure/azure-xplat-cli/blob/dev/lib/util/profile/environment.js +class ENDPOINT_URLS: #pylint: disable=too-few-public-methods,old-style-class,no-init + MANAGEMENT = 'management' + ACTIVE_DIRECTORY_AUTHORITY = 'active_directory_authority' + +_environments = { + ENV_DEFAULT: { + ENDPOINT_URLS.MANAGEMENT: 'https://management.core.windows.net/', + ENDPOINT_URLS.ACTIVE_DIRECTORY_AUTHORITY : 'https://login.microsoftonline.com' + }, + ENV_CHINA: { + ENDPOINT_URLS.MANAGEMENT: 'https://management.core.chinacloudapi.cn/', + ENDPOINT_URLS.ACTIVE_DIRECTORY_AUTHORITY: 'https://login.chinacloudapi.cn' + }, + ENV_US_GOVERNMENT: { + ENDPOINT_URLS.MANAGEMENT: 'https://management.core.usgovcloudapi.net/', + ENDPOINT_URLS.ACTIVE_DIRECTORY_AUTHORITY: 'https://login.microsoftonline.com' + } +} + +def get_env(env_name=None): + if env_name is None: + env_name = ENV_DEFAULT + elif env_name not in _environments: + raise ValueError + return _environments[env_name] + +def get_authority_url(tenant=None, env_name=None): + env = get_env(env_name) + return env[ENDPOINT_URLS.ACTIVE_DIRECTORY_AUTHORITY] + '/' + (tenant or COMMON_TENANT) + +def get_management_endpoint_url(env_name=None): + env = get_env(env_name) + return env[ENDPOINT_URLS.MANAGEMENT] + diff --git a/src/azure/cli/_profile.py b/src/azure/cli/_profile.py index af5b5866778..5945913f4f2 100644 --- a/src/azure/cli/_profile.py +++ b/src/azure/cli/_profile.py @@ -1,98 +1,374 @@ -import collections +from __future__ import print_function +import collections +from codecs import open as codecs_open +import json +import os.path from msrest.authentication import BasicTokenAuthentication -from .main import CONFIG +import adal +from azure.mgmt.resource.subscriptions import (SubscriptionClient, + SubscriptionClientConfiguration) +from .main import ACCOUNT +from ._locale import L +from ._azure_env import (get_authority_url, CLIENT_ID, get_management_endpoint_url, + ENV_DEFAULT, COMMON_TENANT) + +#Names below are used by azure-xplat-cli to persist account information into +#~/.azure/azureProfile.json or osx/keychainer or windows secure storage, +#which azuer-cli will share. +#Please do not rename them unless you know what you are doing. +_IS_DEFAULT_SUBSCRIPTION = 'isDefault' +_SUBSCRIPTION_ID = 'id' +_SUBSCRIPTION_NAME = 'name' +_TENANT_ID = 'tenantId' +_USER_ENTITY = 'user' +_USER_NAME = 'name' +_SUBSCRIPTIONS = 'subscriptions' +_ENVIRONMENT_NAME = 'environmentName' +_STATE = 'state' +_USER_TYPE = 'type' +_USER = 'user' +_SERVICE_PRINCIPAL = 'servicePrincipal' +_SERVICE_PRINCIPAL_ID = 'servicePrincipalId' +_SERVICE_PRINCIPAL_TENANT = 'servicePrincipalTenant' +_TOKEN_ENTRY_USER_ID = 'userId' +#This could mean real access token, or client secret of a service principal +#This naming is no good, but can't change because xplat-cli does so. +_ACCESS_TOKEN = 'accessToken' + +_AUTH_CTX_FACTORY = lambda authority, cache: adal.AuthenticationContext(authority, cache=cache) + +def _read_file_content(file_path): + file_text = None + if os.path.isfile(file_path): + with codecs_open(file_path, 'r', encoding='ascii') as file_to_read: + file_text = file_to_read.read() + return file_text class Profile(object): + def __init__(self, storage=None, auth_ctx_factory=None): + self._storage = storage or ACCOUNT + factory = auth_ctx_factory or _AUTH_CTX_FACTORY + self._creds_cache = CredsCache(factory) + self._subscription_finder = SubscriptionFinder(factory, self._creds_cache.adal_token_cache) + + def find_subscriptions_on_login(self, #pylint: disable=too-many-arguments + interactive, + username, + password, + is_service_principal, + tenant): + self._creds_cache.remove_cached_creds(username) + subscriptions = [] + if interactive: + subscriptions = self._subscription_finder.find_through_interactive_flow() + else: + if is_service_principal: + if not tenant: + raise ValueError(L('Please supply tenant using "--tenant"')) + + subscriptions = self._subscription_finder.find_from_service_principal_id(username, + password, + tenant) + else: + subscriptions = self._subscription_finder.find_from_user_account(username, password) - def __init__(self, storage=CONFIG): - self._storage = storage + if not subscriptions: + raise RuntimeError(L('No subscriptions found for this account.')) + + if is_service_principal: + self._creds_cache.save_service_principal_cred(username, + password, + tenant) + if self._creds_cache.adal_token_cache.has_state_changed: + self._creds_cache.persist_cached_creds() + consolidated = Profile._normalize_properties(self._subscription_finder.user_id, + subscriptions, + is_service_principal, + ENV_DEFAULT) + self._set_subscriptions(consolidated) + return consolidated @staticmethod - def normalize_properties(user, subscriptions): + def _normalize_properties(user, subscriptions, is_service_principal, environment): consolidated = [] for s in subscriptions: consolidated.append({ - 'id': s.id.rpartition('/')[2], - 'name': s.display_name, - 'state': s.state, - 'user': user, - 'active': False + _SUBSCRIPTION_ID: s.id.rpartition('/')[2], + _SUBSCRIPTION_NAME: s.display_name, + _STATE: s.state, + _USER_ENTITY: { + _USER_NAME: user, + _USER_TYPE: _SERVICE_PRINCIPAL if is_service_principal else _USER + }, + _IS_DEFAULT_SUBSCRIPTION: False, + _TENANT_ID: s.tenant_id, + _ENVIRONMENT_NAME: environment }) return consolidated - def set_subscriptions(self, new_subscriptions, access_token): - existing_ones = self.load_subscriptions() - active_one = next((x for x in existing_ones if x['active']), None) - active_subscription_id = active_one['id'] if active_one else None + def _set_subscriptions(self, new_subscriptions): + existing_ones = self.load_cached_subscriptions() + active_one = next((x for x in existing_ones if x.get(_IS_DEFAULT_SUBSCRIPTION)), None) + active_subscription_id = active_one[_SUBSCRIPTION_ID] if active_one else None #merge with existing ones - dic = collections.OrderedDict((x['id'], x) for x in existing_ones) - dic.update((x['id'], x) for x in new_subscriptions) + dic = collections.OrderedDict((x[_SUBSCRIPTION_ID], x) for x in existing_ones) + dic.update((x[_SUBSCRIPTION_ID], x) for x in new_subscriptions) subscriptions = list(dic.values()) if active_one: new_active_one = next( - (x for x in new_subscriptions if x['id'] == active_subscription_id), None) + (x for x in new_subscriptions if x[_SUBSCRIPTION_ID] == active_subscription_id), + None) for s in subscriptions: - s['active'] = False + s[_IS_DEFAULT_SUBSCRIPTION] = False if not new_active_one: new_active_one = new_subscriptions[0] - new_active_one['active'] = True + new_active_one[_IS_DEFAULT_SUBSCRIPTION] = True else: - new_subscriptions[0]['active'] = True + new_subscriptions[0][_IS_DEFAULT_SUBSCRIPTION] = True + + self._cache_subscriptions_to_local_storage(subscriptions) + + def set_active_subscription(self, subscription_id_or_name): + subscriptions = self.load_cached_subscriptions() + + subscription_id_or_name = subscription_id_or_name.lower() + result = [x for x in subscriptions + if subscription_id_or_name == x[_SUBSCRIPTION_ID].lower() or + subscription_id_or_name == x[_SUBSCRIPTION_NAME].lower()] + + if len(result) != 1: + raise ValueError('The subscription of "{}" does not exist or has more than' + ' one match.'.format(subscription_id_or_name)) + + for s in subscriptions: + s[_IS_DEFAULT_SUBSCRIPTION] = False + result[0][_IS_DEFAULT_SUBSCRIPTION] = True + + self._cache_subscriptions_to_local_storage(subscriptions) + + def logout(self, user_or_sp): + subscriptions = self.load_cached_subscriptions() + result = [x for x in subscriptions + if user_or_sp.lower() == x[_USER_ENTITY][_USER_NAME].lower()] + subscriptions = [x for x in subscriptions if x not in result] + + #reset the active subscription if needed + result = [x for x in subscriptions if x.get(_IS_DEFAULT_SUBSCRIPTION)] + if not result and subscriptions: + subscriptions[0][_IS_DEFAULT_SUBSCRIPTION] = True + + self._cache_subscriptions_to_local_storage(subscriptions) - #before adal/python is available, persist tokens with other profile info - for s in new_subscriptions: - s['access_token'] = access_token + self._creds_cache.remove_cached_creds(user_or_sp) - self._save_subscriptions(subscriptions) + + def load_cached_subscriptions(self): + return self._storage.get(_SUBSCRIPTIONS) or [] + + def _cache_subscriptions_to_local_storage(self, subscriptions): + self._storage[_SUBSCRIPTIONS] = subscriptions def get_login_credentials(self): - subscriptions = self.load_subscriptions() + subscriptions = self.load_cached_subscriptions() if not subscriptions: raise ValueError('Please run login to setup account.') - active = [x for x in subscriptions if x['active']] + active = [x for x in subscriptions if x.get(_IS_DEFAULT_SUBSCRIPTION)] if len(active) != 1: raise ValueError('Please run "account set" to select active account.') + active_account = active[0] + + user_type = active_account[_USER_ENTITY][_USER_TYPE] + username_or_sp_id = active_account[_USER_ENTITY][_USER_NAME] + if user_type == _USER: + access_token = self._creds_cache.retrieve_token_for_user(username_or_sp_id, + active_account[_TENANT_ID]) + else: + access_token = self._creds_cache.retrieve_token_for_service_principal( + username_or_sp_id) return BasicTokenAuthentication( - {'access_token': active[0]['access_token']}), active[0]['id'] + {'access_token': access_token}), active_account[_SUBSCRIPTION_ID] - def set_active_subscription(self, subscription_id_or_name): - subscriptions = self.load_subscriptions() - subscription_id_or_name = subscription_id_or_name.lower() - result = [x for x in subscriptions - if subscription_id_or_name == x['id'].lower() or - subscription_id_or_name == x['name'].lower()] +class SubscriptionFinder(object): + '''finds all subscriptions for a user or service principal''' + def __init__(self, auth_context_factory, adal_token_cache, arm_client_factory=None): + self._adal_token_cache = adal_token_cache + self._auth_context_factory = auth_context_factory + self._resource = get_management_endpoint_url(ENV_DEFAULT) + self.user_id = None # will figure out after log user in + self._arm_client_factory = arm_client_factory or \ + (lambda config: SubscriptionClient(config)) #pylint: disable=unnecessary-lambda - if len(result) != 1: - raise ValueError('The subscription of "{}" does not exist or has more than' - ' one match.'.format(subscription_id_or_name)) + def find_from_user_account(self, username, password): + context = self._create_auth_context(COMMON_TENANT) + token_entry = context.acquire_token_with_username_password( + self._resource, + username, + password, + CLIENT_ID) + self.user_id = token_entry[_TOKEN_ENTRY_USER_ID] + result = self._find_using_common_tenant(token_entry[_ACCESS_TOKEN]) + return result + + def find_through_interactive_flow(self): + context = self._create_auth_context(COMMON_TENANT) + code = context.acquire_user_code(self._resource, CLIENT_ID) + print(code['message']) + token_entry = context.acquire_token_with_device_code(self._resource, code, CLIENT_ID) + self.user_id = token_entry[_TOKEN_ENTRY_USER_ID] + result = self._find_using_common_tenant(token_entry[_ACCESS_TOKEN]) + return result + + def find_from_service_principal_id(self, client_id, secret, tenant): + context = self._create_auth_context(tenant, False) + token_entry = context.acquire_token_with_client_credentials( + self._resource, + client_id, + secret) + self.user_id = client_id + result = self._find_using_specific_tenant(tenant, token_entry[_ACCESS_TOKEN]) + return result + + def _create_auth_context(self, tenant, use_token_cache=True): + token_cache = self._adal_token_cache if use_token_cache else None + authority = get_authority_url(tenant, ENV_DEFAULT) + return self._auth_context_factory(authority, token_cache) + + def _find_using_common_tenant(self, access_token): + all_subscriptions = [] + token_credential = BasicTokenAuthentication({'access_token': access_token}) + client = self._arm_client_factory(SubscriptionClientConfiguration(token_credential)) + tenants = client.tenants.list() + for t in tenants: + tenant_id = t.tenant_id + temp_context = self._create_auth_context(tenant_id) + temp_credentials = temp_context.acquire_token(self._resource, self.user_id, CLIENT_ID) + subscriptions = self._find_using_specific_tenant( + tenant_id, + temp_credentials[_ACCESS_TOKEN]) + all_subscriptions.extend(subscriptions) + + return all_subscriptions + def _find_using_specific_tenant(self, tenant, access_token): + token_credential = BasicTokenAuthentication({'access_token': access_token}) + client = self._arm_client_factory(SubscriptionClientConfiguration(token_credential)) + subscriptions = client.subscriptions.list() + all_subscriptions = [] for s in subscriptions: - s['active'] = False - result[0]['active'] = True + setattr(s, 'tenant_id', tenant) + all_subscriptions.append(s) + return all_subscriptions - self._save_subscriptions(subscriptions) +class CredsCache(object): + '''Caches AAD tokena and service principal secrets, and persistence will + also be handled + ''' + def __init__(self, auth_ctx_factory=None): + self._token_file = os.path.expanduser('~/.azure/accessTokens.json') + self._service_principal_creds = [] + self._auth_ctx_factory = auth_ctx_factory or _AUTH_CTX_FACTORY + self.adal_token_cache = None + self._load_creds() + self._resource = get_management_endpoint_url(ENV_DEFAULT) - def logout(self, user): - subscriptions = self.load_subscriptions() - result = [x for x in subscriptions if user.lower() == x['user'].lower()] - subscriptions = [x for x in subscriptions if x not in result] + def persist_cached_creds(self): + #be compatible with azure-xplat-cli, use 'ascii' so to save w/o a BOM + with codecs_open(self._token_file, 'w', encoding='ascii') as cred_file: + items = self.adal_token_cache.read_items() + all_creds = [entry for _, entry in items] + all_creds.extend(self._service_principal_creds) + cred_file.write(json.dumps(all_creds)) + self.adal_token_cache.has_state_changed = False - #reset the active subscription if needed - result = [x for x in subscriptions if x['active']] - if not result and subscriptions: - subscriptions[0]['active'] = True + def retrieve_token_for_user(self, username, tenant): + authority = get_authority_url(tenant, ENV_DEFAULT) + context = self._auth_ctx_factory(authority, cache=self.adal_token_cache) + token_entry = context.acquire_token(self._resource, username, CLIENT_ID) + if not token_entry: #TODO: consider to letting adal-python throw + raise ValueError('Could not retrieve token from local cache, please run \'login\'.') + + if self.adal_token_cache.has_state_changed: + self.persist_cached_creds() + return token_entry[_ACCESS_TOKEN] + + def retrieve_token_for_service_principal(self, sp_id): + matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] + if not matched: + raise ValueError(L('Please run "account set" to select active account.')) + cred = matched[0] + authority_url = get_authority_url(cred[_SERVICE_PRINCIPAL_TENANT], ENV_DEFAULT) + context = self._auth_ctx_factory(authority_url, None) + token_entry = context.acquire_token_with_client_credentials(self._resource, + sp_id, + cred[_ACCESS_TOKEN]) + return token_entry[_ACCESS_TOKEN] + + def _load_creds(self): + if self.adal_token_cache is not None: + return self.adal_token_cache + + json_text = _read_file_content(self._token_file) + if json_text: + json_text = json_text.replace('\n', '') + else: + json_text = '[]' + + all_entries = json.loads(json_text) + self._load_service_principal_creds(all_entries) + real_token = [x for x in all_entries if x not in self._service_principal_creds] + self.adal_token_cache = adal.TokenCache(json.dumps(real_token)) + return self.adal_token_cache + + def save_service_principal_cred(self, client_id, secret, tenant): + entry = { + _SERVICE_PRINCIPAL_ID: client_id, + _SERVICE_PRINCIPAL_TENANT: tenant, + _ACCESS_TOKEN: secret + } + + matched = [x for x in self._service_principal_creds + if client_id == x[_SERVICE_PRINCIPAL_ID] and + tenant == x[_SERVICE_PRINCIPAL_TENANT]] + state_changed = False + if matched: + if matched[0][_ACCESS_TOKEN] != secret: + matched[0] = entry + state_changed = True + else: + self._service_principal_creds.append(entry) + state_changed = True + + if state_changed: + self.persist_cached_creds() + + def _load_service_principal_creds(self, creds): + for c in creds: + if c.get(_SERVICE_PRINCIPAL_ID): + self._service_principal_creds.append(c) + return self._service_principal_creds - self._save_subscriptions(subscriptions) + def remove_cached_creds(self, user_or_sp): + state_changed = False + #clear AAD tokens + tokens = self.adal_token_cache.find({_TOKEN_ENTRY_USER_ID: user_or_sp}) + if tokens: + state_changed = True + self.adal_token_cache.remove(tokens) - def load_subscriptions(self): - return self._storage.get('subscriptions') or [] + #clear service principal creds + matched = [x for x in self._service_principal_creds + if x[_SERVICE_PRINCIPAL_ID] == user_or_sp] + if matched: + state_changed = True + self._service_principal_creds = [x for x in self._service_principal_creds + if x not in matched] - def _save_subscriptions(self, subscriptions): - self._storage['subscriptions'] = subscriptions + if state_changed: + self.persist_cached_creds() diff --git a/src/azure/cli/_session.py b/src/azure/cli/_session.py index 39881574ef6..60c2d32f185 100644 --- a/src/azure/cli/_session.py +++ b/src/azure/cli/_session.py @@ -16,9 +16,10 @@ class Session(collections.MutableMapping): be followed by a call to `save_with_retry` or `save`. ''' - def __init__(self): + def __init__(self, encoding=None): self.filename = None self.data = {} + self._encoding = encoding if encoding else 'utf-8-sig' def load(self, filename, max_age=0): self.filename = filename @@ -28,14 +29,14 @@ def load(self, filename, max_age=0): st = os.stat(self.filename) if st.st_mtime + max_age < time.clock(): self.save() - with codecs_open(self.filename, 'r', encoding='utf-8-sig') as f: + with codecs_open(self.filename, 'r', encoding=self._encoding) as f: self.data = json.load(f) except (OSError, IOError): self.save() def save(self): if self.filename: - with codecs_open(self.filename, 'w', encoding='utf-8-sig') as f: + with codecs_open(self.filename, 'w', encoding=self._encoding) as f: json.dump(self.data, f) def save_with_retry(self, retries=5): diff --git a/src/azure/cli/main.py b/src/azure/cli/main.py index 5113d806d0c..78dc3169fc5 100644 --- a/src/azure/cli/main.py +++ b/src/azure/cli/main.py @@ -7,6 +7,10 @@ from ._session import Session from ._output import OutputProducer +#ACCOUNT contains subscriptions information +# this file will be shared with azure-xplat-cli, which assumes ascii +ACCOUNT = Session('ascii') + # CONFIG provides external configuration options CONFIG = Session() @@ -14,8 +18,12 @@ SESSION = Session() def main(args, file=sys.stdout): #pylint: disable=redefined-builtin - CONFIG.load(os.path.expanduser('~/az.json')) - SESSION.load(os.path.expanduser('~/az.sess'), max_age=3600) + azure_folder = os.path.expanduser('~/.azure') + if not os.path.exists(azure_folder): + os.makedirs(azure_folder) + ACCOUNT.load(os.path.join(azure_folder, 'azureProfile.json')) + CONFIG.load(os.path.join(azure_folder, 'az.json')) + SESSION.load(os.path.join(azure_folder, 'az.sess'), max_age=3600) configure_logging(args, CONFIG) diff --git a/src/azure/cli/tests/test_profile.py b/src/azure/cli/tests/test_profile.py index dd582130d80..2116e98ff3f 100644 --- a/src/azure/cli/tests/test_profile.py +++ b/src/azure/cli/tests/test_profile.py @@ -1,18 +1,37 @@ +import json import unittest -from azure.cli._profile import Profile +import mock +import adal +from azure.cli._profile import Profile, CredsCache, SubscriptionFinder, _AUTH_CTX_FACTORY +from azure.cli._azure_env import ENV_DEFAULT class Test_Profile(unittest.TestCase): @classmethod def setUpClass(cls): + cls.tenant_id = 'microsoft.com' cls.user1 = 'foo@foo.com' cls.id1 = 'subscriptions/1' cls.display_name1 = 'foo account' cls.state1 = 'enabled' cls.subscription1 = SubscriptionStub(cls.id1, cls.display_name1, - cls.state1) - cls.token1 = 'token1' + cls.state1, + cls.tenant_id) + cls.raw_token1 = 'some...secrets' + cls.token_entry1 = { + "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", + "resource": "https://management.core.windows.net/", + "tokenType": "Bearer", + "expiresOn": "2016-03-31T04:26:56.610Z", + "expiresIn": 3599, + "identityProvider": "live.com", + "_authority": "https://login.microsoftonline.com/common", + "isMRRT": True, + "refreshToken": "faked123", + "accessToken": cls.raw_token1, + "userId": cls.user1 + } cls.user2 = 'bar@bar.com' cls.id2 = 'subscriptions/2' @@ -20,122 +39,262 @@ def setUpClass(cls): cls.state2 = 'suspended' cls.subscription2 = SubscriptionStub(cls.id2, cls.display_name2, - cls.state2) - cls.token2 = 'token2' + cls.state2, + cls.tenant_id) def test_normalize(self): - consolidated = Profile.normalize_properties(self.user1, - [self.subscription1]) - self.assertEqual(consolidated[0], { + consolidated = Profile._normalize_properties(self.user1, + [self.subscription1], + False, + ENV_DEFAULT) + expected = { + 'environmentName': 'AzureCloud', 'id': '1', 'name': self.display_name1, 'state': self.state1, - 'user': self.user1, - 'active': False - }) + 'user': { + 'name':self.user1, + 'type':'user' + }, + 'isDefault': False, + 'tenantId': self.tenant_id + } + self.assertEqual(expected, consolidated[0]) def test_update_add_two_different_subscriptions(self): storage_mock = {'subscriptions': None} profile = Profile(storage_mock) #add the first and verify - consolidated = Profile.normalize_properties(self.user1, - [self.subscription1]) - profile.set_subscriptions(consolidated, self.token1) + consolidated = Profile._normalize_properties(self.user1, + [self.subscription1], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) self.assertEqual(len(storage_mock['subscriptions']), 1) subscription1 = storage_mock['subscriptions'][0] - self.assertEqual(subscription1, { + self.assertEqual(subscription1, { + 'environmentName': 'AzureCloud', 'id': '1', 'name': self.display_name1, 'state': self.state1, - 'user': self.user1, - 'access_token': self.token1, - 'active': True + 'user': { + 'name': self.user1, + 'type': 'user' + }, + 'isDefault': True, + 'tenantId': self.tenant_id }) #add the second and verify - consolidated = Profile.normalize_properties(self.user2, - [self.subscription2]) - profile.set_subscriptions(consolidated, self.token2) + consolidated = Profile._normalize_properties(self.user2, + [self.subscription2], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) self.assertEqual(len(storage_mock['subscriptions']), 2) subscription2 = storage_mock['subscriptions'][1] - self.assertEqual(subscription2, { + self.assertEqual(subscription2, { + 'environmentName': 'AzureCloud', 'id': '2', 'name': self.display_name2, 'state': self.state2, - 'user': self.user2, - 'access_token': self.token2, - 'active': True + 'user': { + 'name': self.user2, + 'type': 'user' + }, + 'isDefault': True, + 'tenantId': self.tenant_id }) #verify the old one stays, but no longer active self.assertEqual(storage_mock['subscriptions'][0]['name'], subscription1['name']) - self.assertEqual(storage_mock['subscriptions'][0]['access_token'], - self.token1) - self.assertFalse(storage_mock['subscriptions'][0]['active']) + self.assertFalse(storage_mock['subscriptions'][0]['isDefault']) def test_update_with_same_subscription_added_twice(self): storage_mock = {'subscriptions': None} profile = Profile(storage_mock) #add one twice and verify we will have one but with new token - consolidated = Profile.normalize_properties(self.user1, - [self.subscription1]) - profile.set_subscriptions(consolidated, self.token1) + consolidated = Profile._normalize_properties(self.user1, + [self.subscription1], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) new_subscription1 = SubscriptionStub(self.id1, self.display_name1, - self.state1) - consolidated = Profile.normalize_properties(self.user1, - [new_subscription1]) - profile.set_subscriptions(consolidated, self.token2) + self.state1, + self.tenant_id) + consolidated = Profile._normalize_properties(self.user1, + [new_subscription1], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) self.assertEqual(len(storage_mock['subscriptions']), 1) - self.assertEqual(storage_mock['subscriptions'][0]['access_token'], - self.token2) - self.assertTrue(storage_mock['subscriptions'][0]['active']) + self.assertTrue(storage_mock['subscriptions'][0]['isDefault']) def test_set_active_subscription(self): storage_mock = {'subscriptions': None} profile = Profile(storage_mock) - consolidated = Profile.normalize_properties(self.user1, - [self.subscription1]) - profile.set_subscriptions(consolidated, self.token1) + consolidated = Profile._normalize_properties(self.user1, + [self.subscription1], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) - consolidated = Profile.normalize_properties(self.user2, - [self.subscription2]) - profile.set_subscriptions(consolidated, self.token2) + consolidated = profile._normalize_properties(self.user2, + [self.subscription2], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) subscription1 = storage_mock['subscriptions'][0] subscription2 = storage_mock['subscriptions'][1] - self.assertTrue(subscription2['active']) + self.assertTrue(subscription2['isDefault']) profile.set_active_subscription(subscription1['id']) - self.assertFalse(subscription2['active']) - self.assertTrue(subscription1['active']) + self.assertFalse(subscription2['isDefault']) + self.assertTrue(subscription1['isDefault']) + + @mock.patch('azure.cli._profile._read_file_content', return_value=None) + def test_create_token_cache(self, mock_read_file): + profile = Profile() + cache = profile._creds_cache.adal_token_cache + self.assertFalse(cache.read_items()) + self.assertTrue(mock_read_file.called) - def test_get_login_credentials(self): + @mock.patch('azure.cli._profile._read_file_content', autospec=True) + def test_load_cached_tokens(self, mock_read_file): + mock_read_file.return_value = json.dumps([Test_Profile.token_entry1]) + profile = Profile() + cache = profile._creds_cache.adal_token_cache + matched = cache.find({ + "_authority": "https://login.microsoftonline.com/common", + "_clientId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", + "userId": self.user1 + }) + self.assertEqual(len(matched), 1) + self.assertEqual(matched[0]['accessToken'], self.raw_token1) + + @mock.patch('azure.cli._profile._read_file_content', autospec=True) + @mock.patch('azure.cli._profile.CredsCache.retrieve_token_for_user', autospec=True) + def test_get_login_credentials(self, mock_get_token, mock_read_cred_file): + mock_read_cred_file.return_value = json.dumps([Test_Profile.token_entry1]) + mock_get_token.return_value = Test_Profile.raw_token1 + #setup storage_mock = {'subscriptions': None} profile = Profile(storage_mock) - - consolidated = Profile.normalize_properties(self.user1, - [self.subscription1]) - profile.set_subscriptions(consolidated, self.token1) + consolidated = Profile._normalize_properties(self.user1, + [self.subscription1], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) + #action cred, subscription_id = profile.get_login_credentials() - self.assertEqual(cred.token['access_token'], self.token1) + #verify self.assertEqual(subscription_id, '1') + self.assertEqual(cred.token['access_token'], self.raw_token1) + self.assertEqual(mock_read_cred_file.call_count, 1) + self.assertEqual(mock_get_token.call_count, 1) + + @mock.patch('azure.cli._profile._read_file_content', autospec=True) + @mock.patch('azure.cli._profile.CredsCache.persist_cached_creds', autospec=True) + def test_logout(self, mock_persist_creds, mock_read_cred_file): + #setup + mock_read_cred_file.return_value = json.dumps([Test_Profile.token_entry1]) + + storage_mock = {'subscriptions': None} + profile = Profile(storage_mock) + consolidated = Profile._normalize_properties(self.user1, + [self.subscription1], + False, + ENV_DEFAULT) + profile._set_subscriptions(consolidated) + self.assertEqual(1, len(storage_mock['subscriptions'])) + #action + profile.logout(self.user1) + + #verify + self.assertEqual(0, len(storage_mock['subscriptions'])) + self.assertEqual(mock_read_cred_file.call_count, 1) + self.assertEqual(mock_persist_creds.call_count, 1) + + def test_find_subscriptions_thru_username_password(self): + finder = SubscriptionFinder(lambda _,_2:AuthenticationContextStub(Test_Profile), + None, + lambda _: ArmClientStub(Test_Profile)) + subs = finder.find_from_user_account('foo', 'bar') + self.assertEqual([self.subscription1], subs) + + def test_find_through_interactive_flow(self): + finder = SubscriptionFinder(lambda _,_2:AuthenticationContextStub(Test_Profile), + None, + lambda _: ArmClientStub(Test_Profile)) + subs = finder.find_through_interactive_flow() + self.assertEqual([self.subscription1], subs) + def test_find_from_service_principal_id(self): + finder = SubscriptionFinder(lambda _,_2:AuthenticationContextStub(Test_Profile), + None, + lambda _: ArmClientStub(Test_Profile)) + subs = finder.find_from_service_principal_id('my app', 'my secret', self.tenant_id) + self.assertEqual([self.subscription1], subs) class SubscriptionStub: - def __init__(self, id, display_name, state): + def __init__(self, id, display_name, state, tenant_id): self.id = id self.display_name = display_name self.state = state + self.tenant_id = tenant_id + +class AuthenticationContextStub: + def __init__(self, test_profile_cls, return_token1=True): + #we need to reference some pre-defined test artifacts in Test_Profile + self._test_profile_cls = test_profile_cls + if not return_token1: + raise ValueError('Please update to return other test tokens') + + def acquire_token_with_username_password(self, _, _2, _3, _4): + return self._test_profile_cls.token_entry1 + + def acquire_token_with_device_code(self, _, _2, _3): + return self._test_profile_cls.token_entry1 + + def acquire_token_with_client_credentials(self, _, _2, _3): + return self._test_profile_cls.token_entry1 + + def acquire_token(self, _, _2, _3): + return self._test_profile_cls.token_entry1 + + def acquire_user_code(self, _, _2): + return {'message': 'secret code for you'} + +class ArmClientStub: + class TenantStub: + def __init__(self, tenant_id): + self.tenant_id = tenant_id + + class OperationsStub: + def __init__(self, list_result): + self._list_result = list_result + + def list(self): + return self._list_result + + def __init__(self, test_profile_cls, use_tenant1_and_subscription1=True): + self._test_profile_cls = test_profile_cls + if use_tenant1_and_subscription1: + self.tenants = ArmClientStub.OperationsStub([ArmClientStub.TenantStub(test_profile_cls.tenant_id)]) + self.subscriptions = ArmClientStub.OperationsStub([test_profile_cls.subscription1]) + else: + raise ValueError('Please update to return other test subscriptions') if __name__ == '__main__': unittest.main() diff --git a/src/azure/cli/utils/command_test_util.py b/src/azure/cli/utils/command_test_util.py index 1d81dd0f290..19fbe5577f1 100644 --- a/src/azure/cli/utils/command_test_util.py +++ b/src/azure/cli/utils/command_test_util.py @@ -41,14 +41,24 @@ def generate_tests(self): def gen_test(test_name, command, expected_result): def load_subscriptions_mock(self): #pylint: disable=unused-argument - return [{"id": "00000000-0000-0000-0000-000000000000", - "user": "example@example.com", - "access_token": "access_token", - "state": "Enabled", - "name": "Example", - "active": True}] + return [{ + "id": "00000000-0000-0000-0000-000000000000", + "user": { + "name": "example@example.com", + "type": "user" + }, + "state": "Enabled", + "name": "Example", + "tenantId": "123", + "isDefault": True}] - @mock.patch('azure.cli._profile.Profile.load_subscriptions', load_subscriptions_mock) + def get_user_access_token_mock(_, _1, _2): #pylint: disable=unused-argument + return 'top-secret-token-for-you' + + @mock.patch('azure.cli._profile.Profile.load_cached_subscriptions', + load_subscriptions_mock) + @mock.patch('azure.cli._profile.CredsCache.retrieve_token_for_user', + get_user_access_token_mock) @self.my_vcr.use_cassette(test_name + '.yaml', filter_headers=CommandTestGenerator.FILTER_HEADERS) def test(self): diff --git a/src/command_modules/azure-cli-component/README.rst b/src/command_modules/azure-cli-component/README.rst new file mode 100644 index 00000000000..19b0715755a --- /dev/null +++ b/src/command_modules/azure-cli-component/README.rst @@ -0,0 +1,7 @@ +Microsoft Azure CLI 'component' Command Module +================================== + +This package is for the 'component' module. +i.e. 'az component' + +This package has [not] been tested [much] with Python 2.7, 3.4 and 3.5. diff --git a/src/command_modules/azure-cli-components/azure/__init__.py b/src/command_modules/azure-cli-component/azure/__init__.py similarity index 100% rename from src/command_modules/azure-cli-components/azure/__init__.py rename to src/command_modules/azure-cli-component/azure/__init__.py diff --git a/src/command_modules/azure-cli-components/azure/cli/__init__.py b/src/command_modules/azure-cli-component/azure/cli/__init__.py similarity index 100% rename from src/command_modules/azure-cli-components/azure/cli/__init__.py rename to src/command_modules/azure-cli-component/azure/cli/__init__.py diff --git a/src/command_modules/azure-cli-components/azure/cli/command_modules/__init__.py b/src/command_modules/azure-cli-component/azure/cli/command_modules/__init__.py similarity index 100% rename from src/command_modules/azure-cli-components/azure/cli/command_modules/__init__.py rename to src/command_modules/azure-cli-component/azure/cli/command_modules/__init__.py diff --git a/src/command_modules/azure-cli-components/azure/cli/command_modules/components/__init__.py b/src/command_modules/azure-cli-component/azure/cli/command_modules/component/__init__.py similarity index 94% rename from src/command_modules/azure-cli-components/azure/cli/command_modules/components/__init__.py rename to src/command_modules/azure-cli-component/azure/cli/command_modules/component/__init__.py index cc776f3bc62..40076054a91 100644 --- a/src/command_modules/azure-cli-components/azure/cli/command_modules/components/__init__.py +++ b/src/command_modules/azure-cli-component/azure/cli/command_modules/component/__init__.py @@ -20,16 +20,15 @@ @command_table.command('component list') @command_table.description(L('List the installed components.')) def list_components(args): #pylint: disable=unused-argument - components = sorted(["%s (%s)" % (dist.key.replace(COMPONENT_PREFIX, ''), dist.version) - for dist in pip.get_installed_distributions(local_only=True) - if dist.key.startswith(COMPONENT_PREFIX)]) - print('\n'.join(components)) + return sorted([{'name': dist.key.replace(COMPONENT_PREFIX, ''), 'version': dist.version} + for dist in pip.get_installed_distributions(local_only=True) + if dist.key.startswith(COMPONENT_PREFIX)], key=lambda x: x['name']) def _install_or_update(component_name, version, link, private, upgrade=False): if not component_name: raise IncorrectUsageError(L('Specify a component name.')) found = bool([dist for dist in pip.get_installed_distributions(local_only=True) - if dist.key == COMPONENT_PREFIX+component_name]) + if dist.key == COMPONENT_PREFIX + component_name]) if found and not upgrade: raise RuntimeError("Component already installed.") else: diff --git a/src/command_modules/azure-cli-components/requirements.txt b/src/command_modules/azure-cli-component/requirements.txt similarity index 100% rename from src/command_modules/azure-cli-components/requirements.txt rename to src/command_modules/azure-cli-component/requirements.txt diff --git a/src/command_modules/azure-cli-components/setup.py b/src/command_modules/azure-cli-component/setup.py similarity index 96% rename from src/command_modules/azure-cli-components/setup.py rename to src/command_modules/azure-cli-component/setup.py index d70ee6a69a2..5a626f600e0 100644 --- a/src/command_modules/azure-cli-components/setup.py +++ b/src/command_modules/azure-cli-component/setup.py @@ -45,7 +45,7 @@ README = f.read() setup( - name='azure-cli-components', + name='azure-cli-component', version=VERSION, description='Microsoft Azure Command-Line Tools', long_description=README, @@ -56,7 +56,7 @@ classifiers=CLASSIFIERS, namespace_packages = ['azure.cli.command_modules'], packages=[ - 'azure.cli.command_modules.components', + 'azure.cli.command_modules.component', ], install_requires=DEPENDENCIES, ) diff --git a/src/command_modules/azure-cli-components/README.rst b/src/command_modules/azure-cli-components/README.rst deleted file mode 100644 index 281a5b7c8b8..00000000000 --- a/src/command_modules/azure-cli-components/README.rst +++ /dev/null @@ -1,7 +0,0 @@ -Microsoft Azure CLI 'components' Command Module -================================== - -This package is for the 'components' module. -i.e. 'az components' - -This package has [not] been tested [much] with Python 2.7, 3.4 and 3.5. diff --git a/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/account.py b/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/account.py index 982aff888d7..a0821d2e626 100644 --- a/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/account.py +++ b/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/account.py @@ -8,7 +8,7 @@ COMMAND_TABLES.append(command_table) @command_table.command('account list', description=L('List the imported subscriptions.')) -def list_subscriptions(args): #pylint: disable=unused-argument +def list_subscriptions(_): """ type: command long-summary: | @@ -20,7 +20,7 @@ def list_subscriptions(args): #pylint: disable=unused-argument text: example details """ profile = Profile() - subscriptions = profile.load_subscriptions() + subscriptions = profile.load_cached_subscriptions() return subscriptions diff --git a/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/login.py b/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/login.py index a970b98483d..56f0b479c0f 100644 --- a/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/login.py +++ b/src/command_modules/azure-cli-profile/azure/cli/command_modules/profile/login.py @@ -1,13 +1,3 @@ -from __future__ import print_function - -from msrest.authentication import BasicTokenAuthentication -from adal import (acquire_token_with_username_password, - acquire_token_with_client_credentials, - acquire_user_code, - acquire_token_with_device_code) -from azure.mgmt.resource.subscriptions import (SubscriptionClient, - SubscriptionClientConfiguration) - from azure.cli._profile import Profile from azure.cli.commands import CommandTable from azure.cli._locale import L @@ -27,12 +17,14 @@ @command_table.option('--password -p', help=L('user password or client secret, will prompt if not given.')) @command_table.option('--service-principal', + action='store_true', help=L('the credential represents a service principal.')) @command_table.option('--tenant -t', help=L('the tenant associated with the service principal.')) def login(args): interactive = False username = args.get('username') + password = None if username: password = args.get('password') if not password: @@ -41,41 +33,14 @@ def login(args): else: interactive = True + is_service_principal = args.get('service-principal') tenant = args.get('tenant') - authority = _get_authority_url(tenant) - if interactive: - user_code = acquire_user_code(authority) - print(user_code['message']) - credentials = acquire_token_with_device_code(authority, user_code) - username = credentials['userId'] - else: - if args.get('service-principal'): - if not tenant: - raise ValueError(L('Please supply tenant using "--tenant"')) - - credentials = acquire_token_with_client_credentials( - authority, - username, - password) - else: - credentials = acquire_token_with_username_password( - authority, - username, - password) - token_credential = BasicTokenAuthentication({'access_token': credentials['accessToken']}) - client = SubscriptionClient(SubscriptionClientConfiguration(token_credential)) - subscriptions = client.subscriptions.list() - - if not subscriptions: - raise RuntimeError(L('No subscriptions found for this account.')) - - #keep useful properties and not json serializable profile = Profile() - consolidated = Profile.normalize_properties(username, subscriptions) - profile.set_subscriptions(consolidated, credentials['accessToken']) - + subscriptions = profile.find_subscriptions_on_login( + interactive, + username, + password, + is_service_principal, + tenant) return list(subscriptions) - -def _get_authority_url(tenant=None): - return 'https://login.microsoftonline.com/{}'.format(tenant or 'common') diff --git a/src/command_modules/azure-cli-profile/setup.py b/src/command_modules/azure-cli-profile/setup.py index 5f5a164d88a..100c2c14203 100644 --- a/src/command_modules/azure-cli-profile/setup.py +++ b/src/command_modules/azure-cli-profile/setup.py @@ -39,7 +39,7 @@ DEPENDENCIES = [ 'azure-cli', 'azure==2.0.0rc1', - 'adal==0.2.0' #from internal index server. + 'adal==0.2.1', #from internal index server. ] with open('README.rst', 'r', encoding='utf-8') as f: