Skip to content

Commit 6bc01c9

Browse files
committed
arm-client
1 parent 68e5f06 commit 6bc01c9

4 files changed

Lines changed: 171 additions & 71 deletions

File tree

‎src/azure-cli-core/azure/cli/core/_profile.py‎

Lines changed: 50 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,22 @@
2525
_SUBSCRIPTION_ID = 'id'
2626
_SUBSCRIPTION_NAME = 'name'
2727

28-
# Tenant of the token which is used to list the subscription
29-
_TENANT_ID = 'tenantId'
30-
3128
# More information on the token tenant. Maps to properties in 'Tenants - List' REST API
3229
# https://learn.microsoft.com/en-us/rest/api/resources/tenants/list
3330
_TENANT_DEFAULT_DOMAIN = 'tenantDefaultDomain' # defaultDomain
3431
_TENANT_DISPLAY_NAME = 'tenantDisplayName' # displayName
3532

33+
# ARM resource properties
34+
_ID = 'id'
35+
_DISPLAY_NAME = 'displayName'
36+
_MANAGED_BY_TENANTS = 'managedByTenants'
37+
# Tenant of the token which is used to list the subscription
38+
_TENANT_ID = 'tenantId'
39+
_DEFAULT_DOMAIN = "defaultDomain"
40+
3641
# Home tenant of the subscription. Maps to tenantId in 'Subscriptions - List' REST API
3742
# https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list
3843
_HOME_TENANT_ID = 'homeTenantId'
39-
_MANAGED_BY_TENANTS = 'managedByTenants'
4044
_USER_ENTITY = 'user'
4145
_USER_NAME = 'name'
4246
_CLIENT_ID = 'clientId'
@@ -109,16 +113,16 @@ def _attach_token_tenant(subscription, tenant, tenant_id_description=None):
109113
- TenantIdDescription has default_domain, mapped to tenant_default_domain
110114
- TenantIdDescription has display_name, mapped to tenant_display_name
111115
"""
112-
if hasattr(subscription, "tenant_id"):
113-
setattr(subscription, 'home_tenant_id', subscription.tenant_id)
114-
setattr(subscription, 'tenant_id', tenant)
116+
if _TENANT_ID in subscription:
117+
subscription[_HOME_TENANT_ID] = subscription[_TENANT_ID]
118+
subscription[_TENANT_ID] = tenant
115119

116120
# Attach tenant_default_domain, if available
117-
if tenant_id_description and hasattr(tenant_id_description, "default_domain"):
118-
setattr(subscription, 'tenant_default_domain', tenant_id_description.default_domain)
121+
if tenant_id_description and _DEFAULT_DOMAIN in tenant_id_description:
122+
subscription[_TENANT_DEFAULT_DOMAIN] = tenant_id_description[_DEFAULT_DOMAIN]
119123
# Attach display_name, if available
120-
if tenant_id_description and hasattr(tenant_id_description, "display_name"):
121-
setattr(subscription, 'tenant_display_name', tenant_id_description.display_name)
124+
if tenant_id_description and _DISPLAY_NAME in tenant_id_description:
125+
subscription[_TENANT_DISPLAY_NAME] = tenant_id_description[_DISPLAY_NAME]
122126

123127

124128
# pylint: disable=too-many-lines,too-many-instance-attributes,unused-argument
@@ -206,7 +210,7 @@ def login(self,
206210
raise CLIError("No subscriptions found for {}.".format(username))
207211

208212
if allow_no_subscriptions:
209-
t_list = [s.tenant_id for s in subscriptions]
213+
t_list = [s[_TENANT_ID] for s in subscriptions]
210214
bare_tenants = [t for t in subscription_finder.tenants if t not in t_list]
211215
tenant_accounts = self._build_tenant_level_accounts(bare_tenants)
212216
subscriptions.extend(tenant_accounts)
@@ -442,15 +446,15 @@ def _normalize_properties(self, user, subscriptions, is_service_principal, cert_
442446
consolidated = []
443447
for s in subscriptions:
444448
subscription_dict = {
445-
_SUBSCRIPTION_ID: s.id.rpartition('/')[2],
446-
_SUBSCRIPTION_NAME: s.display_name,
447-
_STATE: s.state,
449+
_SUBSCRIPTION_ID: s[_ID].rpartition('/')[2],
450+
_SUBSCRIPTION_NAME: s[_DISPLAY_NAME],
451+
_STATE: s[_STATE],
448452
_USER_ENTITY: {
449453
_USER_NAME: user,
450454
_USER_TYPE: _SERVICE_PRINCIPAL if is_service_principal else _USER
451455
},
452456
_IS_DEFAULT_SUBSCRIPTION: False,
453-
_TENANT_ID: s.tenant_id,
457+
_TENANT_ID: s[_TENANT_ID],
454458
_ENVIRONMENT_NAME: self.cli_ctx.cloud.name
455459
}
456460

@@ -769,24 +773,25 @@ def find_using_common_tenant(self, username, credential=None):
769773
all_subscriptions = []
770774
empty_tenants = []
771775
interaction_required_tenants = []
776+
TENANT_ID_NAME = 'tenantIdName'
772777

773778
client = self._create_subscription_client(credential)
774779
# https://learn.microsoft.com/en-us/rest/api/resources/tenants/list
775-
tenants = client.tenants.list()
780+
tenants = client.tenant_list()
776781

777782
for t in tenants:
778-
tenant_id = t.tenant_id
783+
tenant_id = t[_TENANT_ID]
779784
# display_name is available since /tenants?api-version=2018-06-01,
780785
# not available in /tenants?api-version=2016-06-01
781-
if not hasattr(t, 'display_name'):
782-
t.display_name = None
786+
if _DISPLAY_NAME not in t:
787+
t[_DISPLAY_NAME] = None
783788

784-
t.tenant_id_name = tenant_id
785-
if t.display_name:
789+
t[TENANT_ID_NAME] = tenant_id
790+
if t[_DISPLAY_NAME]:
786791
# e.g. '72f988bf-86f1-41af-91ab-2d7cd011db47 Microsoft'
787-
t.tenant_id_name = "{} '{}'".format(tenant_id, t.display_name)
792+
t[TENANT_ID_NAME] = "{} '{}'".format(tenant_id, t[_DISPLAY_NAME])
788793

789-
logger.info("Finding subscriptions under tenant %s", t.tenant_id_name)
794+
logger.info("Finding subscriptions under tenant %s", t[TENANT_ID_NAME])
790795

791796
identity = _create_identity_instance(self.cli_ctx, self._authority, tenant_id=tenant_id)
792797

@@ -801,7 +806,7 @@ def find_using_common_tenant(self, username, credential=None):
801806
# tenant specific, like the account was disabled, being blocked by MFA. For such errors,
802807
# we continue with other tenants.
803808
# As we don't check AADSTS error code, show the original error message for user's reference.
804-
logger.warning("Authentication failed against tenant %s: %s", t.tenant_id_name, ex)
809+
logger.warning("Authentication failed against tenant %s: %s", t[TENANT_ID_NAME], ex)
805810
interaction_required_tenants.append(t)
806811
continue
807812

@@ -812,12 +817,12 @@ def find_using_common_tenant(self, username, credential=None):
812817
for sub_to_add in subscriptions:
813818
add_sub = True
814819
for sub_to_compare in all_subscriptions:
815-
if sub_to_add.subscription_id == sub_to_compare.subscription_id:
820+
if sub_to_add['subscriptionId'] == sub_to_compare['subscriptionId']:
816821
logger.warning("Subscription %s '%s' can be accessed from tenants %s(default) and %s. "
817822
"To select a specific tenant when accessing this subscription, "
818823
"use 'az login --tenant TENANT_ID'.",
819-
sub_to_add.subscription_id, sub_to_add.display_name,
820-
sub_to_compare.tenant_id, sub_to_add.tenant_id)
824+
sub_to_add['subscriptionId'], sub_to_add[_DISPLAY_NAME],
825+
sub_to_compare[_TENANT_ID], sub_to_add[_TENANT_ID])
821826
add_sub = False
822827
break
823828
if add_sub:
@@ -828,14 +833,14 @@ def find_using_common_tenant(self, username, credential=None):
828833
logger.warning("The following tenants don't contain accessible subscriptions. "
829834
"Use `az login --allow-no-subscriptions` to have tenant level access.")
830835
for t in empty_tenants:
831-
logger.warning("%s", t.tenant_id_name)
836+
logger.warning("%s", t[TENANT_ID_NAME])
832837

833838
# Show warning for InteractionRequired tenants
834839
if interaction_required_tenants:
835840
logger.warning("If you need to access subscriptions in the following tenants, please use "
836841
"`az login --tenant TENANT_ID`.")
837842
for t in interaction_required_tenants:
838-
logger.warning("%s", t.tenant_id_name)
843+
logger.warning("%s", t[TENANT_ID_NAME])
839844
return all_subscriptions
840845

841846
def find_using_specific_tenant(self, tenant, credential, tenant_id_description=None):
@@ -845,7 +850,7 @@ def find_using_specific_tenant(self, tenant, credential, tenant_id_description=N
845850
"""
846851
client = self._create_subscription_client(credential)
847852
# https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list
848-
subscriptions = client.subscriptions.list()
853+
subscriptions = client.subscription_list()
849854
all_subscriptions = []
850855
for s in subscriptions:
851856
_attach_token_tenant(s, tenant, tenant_id_description=tenant_id_description)
@@ -854,43 +859,29 @@ def find_using_specific_tenant(self, tenant, credential, tenant_id_description=N
854859
return all_subscriptions
855860

856861
def _create_subscription_client(self, credential):
857-
from azure.cli.core.profiles import ResourceType, get_api_version
858-
from azure.cli.core.profiles._shared import get_client_class
859-
from azure.cli.core.commands.client_factory import _prepare_mgmt_client_kwargs_track2
860-
861-
client_type = get_client_class(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS)
862-
if client_type is None:
863-
from azure.cli.core.azclierror import CLIInternalError
864-
raise CLIInternalError("Unable to get '{}' in profile '{}'"
865-
.format(ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS, self.cli_ctx.cloud.profile))
866-
api_version = get_api_version(self.cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS)
867-
client_kwargs = _prepare_mgmt_client_kwargs_track2(self.cli_ctx, credential)
868-
869-
client = client_type(credential, api_version=api_version,
870-
base_url=self.cli_ctx.cloud.endpoints.resource_manager,
871-
**client_kwargs)
872-
return client
862+
from .arm import ARMClient
863+
return ARMClient(self.cli_ctx, credential=credential)
873864

874865

875866
def _transform_subscription_for_multiapi(s, s_dict):
876867
"""
877868
Transforms properties from Subscriptions - List 2019-06-01 and later to the subscription dict.
878869
879-
:param s: subscription object
880-
:param s_dict: subscription dict
870+
:param s: subscription dict
871+
:param s_dict: subscription dict persisted as an "account"
881872
"""
882-
if hasattr(s, 'home_tenant_id'):
883-
s_dict[_HOME_TENANT_ID] = s.home_tenant_id
884-
if hasattr(s, 'tenant_default_domain'):
885-
s_dict[_TENANT_DEFAULT_DOMAIN] = s.tenant_default_domain
886-
if hasattr(s, 'tenant_display_name'):
887-
s_dict[_TENANT_DISPLAY_NAME] = s.tenant_display_name
888-
889-
if hasattr(s, 'managed_by_tenants'):
890-
if s.managed_by_tenants is None:
873+
if _HOME_TENANT_ID in s:
874+
s_dict[_HOME_TENANT_ID] = s[_HOME_TENANT_ID]
875+
if _TENANT_DEFAULT_DOMAIN in s:
876+
s_dict[_TENANT_DEFAULT_DOMAIN] = s[_TENANT_DEFAULT_DOMAIN]
877+
if _TENANT_DISPLAY_NAME in s:
878+
s_dict[_TENANT_DISPLAY_NAME] = s[_TENANT_DISPLAY_NAME]
879+
880+
if _MANAGED_BY_TENANTS in s:
881+
if s[_MANAGED_BY_TENANTS] is None:
891882
s_dict[_MANAGED_BY_TENANTS] = None
892883
else:
893-
s_dict[_MANAGED_BY_TENANTS] = [{_TENANT_ID: t.tenant_id} for t in s.managed_by_tenants]
884+
s_dict[_MANAGED_BY_TENANTS] = [{_TENANT_ID: t[_TENANT_ID]} for t in s[_MANAGED_BY_TENANTS]]
894885

895886

896887
def _create_identity_instance(cli_ctx, *args, **kwargs):
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
7+
"""
8+
A light-weight ARM client.
9+
"""
10+
11+
from ._arm_client import ARMClient, ARMError
12+
13+
__all__ = [
14+
"ARMClient",
15+
"ARMError"
16+
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
# pylint: disable=line-too-long, redefined-builtin, too-many-public-methods
7+
8+
import json
9+
10+
from azure.cli.core.util import send_raw_request
11+
from azure.cli.core.auth.util import resource_to_scopes
12+
from azure.cli.core.azclierror import HTTPError
13+
14+
15+
class ARMClient:
16+
"""A lightweight ARM client.
17+
"""
18+
def __init__(self, cli_ctx, credential=None):
19+
self._cli_ctx = cli_ctx
20+
self._credential = credential
21+
self._scopes = resource_to_scopes(cli_ctx.cloud.endpoints.microsoft_graph_resource_id)
22+
23+
# https://management.core.windows.net/
24+
self._resource = cli_ctx.cloud.endpoints.active_directory_resource_id
25+
26+
# https://management.azure.com
27+
self._endpoint = cli_ctx.cloud.endpoints.resource_manager.rstrip('/')
28+
29+
from azure.cli.core.profiles import ResourceType, get_api_version
30+
self._api_version = get_api_version(cli_ctx, ResourceType.MGMT_RESOURCE_SUBSCRIPTIONS)
31+
32+
def _send(self, method, url, param=None, body=None):
33+
url = f'{self._endpoint}{url}?api-version={self._api_version}'
34+
35+
if body:
36+
body = json.dumps(body)
37+
38+
list_result = []
39+
is_list_result = False
40+
41+
while True:
42+
try:
43+
r = send_raw_request(self._cli_ctx, method, url, resource=self._resource, uri_parameters=param,
44+
body=body, credential=self._credential)
45+
except HTTPError as ex:
46+
raise ARMError(ex.response.json()['error']['message'], ex.response) from ex
47+
# Other exceptions like AuthenticationError should not be handled here, so we don't catch CLIError
48+
49+
if r.text:
50+
dic = r.json()
51+
52+
# The result is a list. Add value to list_result.
53+
if 'value' in dic:
54+
is_list_result = True
55+
list_result.extend(dic['value'])
56+
57+
# Follow nextLink if available
58+
# https://learn.microsoft.com/en-us/rest/api/azure/?view=rest-resources-2022-12-01#async-operations-throttling-and-paging
59+
if 'nextLink' in dic:
60+
url = dic['nextLink']
61+
continue
62+
63+
# Return a list
64+
if is_list_result:
65+
# 'value' can be empty list [], so we can't determine if the result is a list only by
66+
# bool(list_result)
67+
return list_result
68+
69+
# Return a single object
70+
return r.json()
71+
return None
72+
73+
def tenant_list(self):
74+
# https://learn.microsoft.com/en-us/rest/api/resources/tenants/list
75+
result = self._send("GET", "/tenants")
76+
return result
77+
78+
def subscription_list(self):
79+
# https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list
80+
result = self._send("GET", "/subscriptions")
81+
return result
82+
83+
84+
class ARMError(Exception):
85+
def __init__(self, message, response):
86+
super().__init__(message)
87+
self.response = response

‎src/azure-cli-core/azure/cli/core/util.py‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -869,7 +869,7 @@ def check_connectivity(url='https://azure.microsoft.com', max_retries=5, timeout
869869

870870
def send_raw_request(cli_ctx, method, url, headers=None, uri_parameters=None, # pylint: disable=too-many-locals,too-many-branches,too-many-statements
871871
body=None, skip_authorization_header=False, resource=None, output_file=None,
872-
generated_client_request_id_name='x-ms-client-request-id'):
872+
generated_client_request_id_name='x-ms-client-request-id', credential=None):
873873
import uuid
874874
from requests import Session, Request
875875
from requests.structures import CaseInsensitiveDict
@@ -976,18 +976,24 @@ def send_raw_request(cli_ctx, method, url, headers=None, uri_parameters=None, #
976976
# But there are APIs which don't require subscription ID, like /subscriptions, /tenants
977977
# TODO: In the future when multi-tenant subscription is supported, we won't be able to uniquely identify
978978
# the token from subscription anymore.
979-
token_subscription = None
980-
if url.lower().startswith(endpoints.resource_manager.rstrip('/')):
981-
token_subscription = _extract_subscription_id(url)
982-
if token_subscription:
983-
logger.debug('Retrieving token for resource %s, subscription %s', resource, token_subscription)
984-
token_info, _, _ = profile.get_raw_token(resource, subscription=token_subscription)
979+
if credential:
980+
from .auth.util import resource_to_scopes
981+
token, _ = credential.get_token(*resource_to_scopes(resource))
982+
headers = headers or {}
983+
headers['Authorization'] = '{} {}'.format('Bearer', token)
985984
else:
986-
logger.debug('Retrieving token for resource %s', resource)
987-
token_info, _, _ = profile.get_raw_token(resource)
988-
token_type, token, _ = token_info
989-
headers = headers or {}
990-
headers['Authorization'] = '{} {}'.format(token_type, token)
985+
token_subscription = None
986+
if url.lower().startswith(endpoints.resource_manager.rstrip('/')):
987+
token_subscription = _extract_subscription_id(url)
988+
if token_subscription:
989+
logger.debug('Retrieving token for resource %s, subscription %s', resource, token_subscription)
990+
token_info, _, _ = profile.get_raw_token(resource, subscription=token_subscription)
991+
else:
992+
logger.debug('Retrieving token for resource %s', resource)
993+
token_info, _, _ = profile.get_raw_token(resource)
994+
token_type, token, _ = token_info
995+
headers = headers or {}
996+
headers['Authorization'] = '{} {}'.format(token_type, token)
991997
else:
992998
logger.warning("Can't derive appropriate Azure AD resource from --url to acquire an access token. "
993999
"If access token is required, use --resource to specify the resource")

0 commit comments

Comments
 (0)