-
Notifications
You must be signed in to change notification settings - Fork 3.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Core][Profile] Add tenant
parameter to get_raw_token
#11798
Changes from 13 commits
fbfe691
7722eb7
cd790da
20ff1d4
0a61ed4
4243726
9f52ccd
15e8062
16d2ae8
bc4dff2
e96810e
ed68f10
b18bb11
19509df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -587,31 +587,42 @@ def get_refresh_token(self, resource=None, | |||||||||||||||
sp_secret = self._creds_cache.retrieve_secret_of_service_principal(username_or_sp_id) | ||||||||||||||||
return username_or_sp_id, sp_secret, None, str(account[_TENANT_ID]) | ||||||||||||||||
|
||||||||||||||||
def get_raw_token(self, resource=None, subscription=None): | ||||||||||||||||
def get_raw_token(self, resource=None, subscription=None, tenant=None): | ||||||||||||||||
if subscription and tenant: | ||||||||||||||||
raise CLIError("Please specify only one of subscription and tenant, not both") | ||||||||||||||||
account = self.get_subscription(subscription) | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if subscription is not specified, account is empty? then can we get the user_type below? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If subscription is not specified, it retrieves the |
||||||||||||||||
user_type = account[_USER_ENTITY][_USER_TYPE] | ||||||||||||||||
username_or_sp_id = account[_USER_ENTITY][_USER_NAME] | ||||||||||||||||
resource = resource or self.cli_ctx.cloud.endpoints.active_directory_resource_id | ||||||||||||||||
|
||||||||||||||||
identity_type, identity_id = Profile._try_parse_msi_account_name(account) | ||||||||||||||||
if identity_type: | ||||||||||||||||
# MSI | ||||||||||||||||
if tenant: | ||||||||||||||||
raise CLIError("Tenant shouldn't be specified for MSI account") | ||||||||||||||||
msi_creds = MsiAccountTypes.msi_auth_factory(identity_type, identity_id, resource) | ||||||||||||||||
msi_creds.set_token() | ||||||||||||||||
token_entry = msi_creds.token | ||||||||||||||||
creds = (token_entry['token_type'], token_entry['access_token'], token_entry) | ||||||||||||||||
elif in_cloud_console() and account[_USER_ENTITY].get(_CLOUD_SHELL_ID): | ||||||||||||||||
# Cloud Shell | ||||||||||||||||
if tenant: | ||||||||||||||||
raise CLIError("Tenant shouldn't be specified for Cloud Shell account") | ||||||||||||||||
creds = self._get_token_from_cloud_shell(resource) | ||||||||||||||||
|
||||||||||||||||
elif user_type == _USER: | ||||||||||||||||
creds = self._creds_cache.retrieve_token_for_user(username_or_sp_id, | ||||||||||||||||
account[_TENANT_ID], resource) | ||||||||||||||||
else: | ||||||||||||||||
creds = self._creds_cache.retrieve_token_for_service_principal(username_or_sp_id, | ||||||||||||||||
resource, | ||||||||||||||||
account[_TENANT_ID]) | ||||||||||||||||
tenant_dest = tenant if tenant else account[_TENANT_ID] | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when subscription is specified, it is checked if exist in cached file in function "get_subscription", is there anyway we can have a pre-check before send retrieve token request? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For a user account we don't need to check if the tenant is in A service principal is a binding between an app and a tenant, in other words, a projection of an app in a tenant.
Please also see Application and service principal objects in Azure Active Directory In
Even if for app credential, service principal : access token(credential) is a 1:1 mapping, we can't tell whether the credential is valid in other tenants until the AAD server returns error. I prefer not to do the bet. So to retrieve the credential, we need to use both azure-cli/src/azure-cli-core/azure/cli/core/_profile.py Lines 940 to 941 in fbfe691
Therefore, for a Service Principal to work across tenants, we need to run |
||||||||||||||||
if user_type == _USER: | ||||||||||||||||
# User | ||||||||||||||||
creds = self._creds_cache.retrieve_token_for_user(username_or_sp_id, | ||||||||||||||||
tenant_dest, resource) | ||||||||||||||||
else: | ||||||||||||||||
# Service Principal | ||||||||||||||||
creds = self._creds_cache.retrieve_token_for_service_principal(username_or_sp_id, | ||||||||||||||||
resource, | ||||||||||||||||
tenant_dest) | ||||||||||||||||
return (creds, | ||||||||||||||||
str(account[_SUBSCRIPTION_ID]), | ||||||||||||||||
str(account[_TENANT_ID])) | ||||||||||||||||
None if tenant else str(account[_SUBSCRIPTION_ID]), | ||||||||||||||||
jiasli marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||
str(tenant if tenant else account[_TENANT_ID])) | ||||||||||||||||
|
||||||||||||||||
def refresh_accounts(self, subscription_finder=None): | ||||||||||||||||
subscriptions = self.load_cached_subscriptions() | ||||||||||||||||
|
@@ -926,12 +937,21 @@ def retrieve_token_for_user(self, username, tenant, resource): | |||||||||||||||
|
||||||||||||||||
def retrieve_token_for_service_principal(self, sp_id, resource, tenant, use_cert_sn_issuer=False): | ||||||||||||||||
self.load_adal_token_cache() | ||||||||||||||||
matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID] and | ||||||||||||||||
tenant == x[_SERVICE_PRINCIPAL_TENANT]] | ||||||||||||||||
matched = [x for x in self._service_principal_creds if sp_id == x[_SERVICE_PRINCIPAL_ID]] | ||||||||||||||||
if not matched: | ||||||||||||||||
raise CLIError("Please run 'az account set' to select active account.") | ||||||||||||||||
cred = matched[0] | ||||||||||||||||
context = self._auth_ctx_factory(self._ctx, cred[_SERVICE_PRINCIPAL_TENANT], None) | ||||||||||||||||
raise CLIError("Could not retrieve credential from local cache for service principal {}. " | ||||||||||||||||
"Please run 'az login' for this service principal." | ||||||||||||||||
.format(sp_id)) | ||||||||||||||||
matched_with_tenant = [x for x in matched if tenant == x[_SERVICE_PRINCIPAL_TENANT]] | ||||||||||||||||
if matched_with_tenant: | ||||||||||||||||
cred = matched_with_tenant[0] | ||||||||||||||||
else: | ||||||||||||||||
logger.warning("Could not retrieve credential from local cache for service principal %s under tenant %s. " | ||||||||||||||||
"Trying credential under tenant %s, assuming that is an app credential.", | ||||||||||||||||
sp_id, tenant, matched[0][_SERVICE_PRINCIPAL_TENANT]) | ||||||||||||||||
cred = matched[0] | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So if I only login with tenant A, and run There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope. It will return the token for tenant B automatically using the credential for tenant A. That's why it is called single-sign-on. Detailed description and test commands can be found at #11798 (comment) +@qianwens for awareness |
||||||||||||||||
|
||||||||||||||||
context = self._auth_ctx_factory(self._ctx, tenant, None) | ||||||||||||||||
sp_auth = ServicePrincipalAuth(cred.get(_ACCESS_TOKEN, None) or | ||||||||||||||||
cred.get(_SERVICE_PRINCIPAL_CERT_FILE, None), | ||||||||||||||||
use_cert_sn_issuer) | ||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -716,6 +716,17 @@ def test_get_raw_token(self, mock_get_token, mock_read_cred_file): | |
self.assertEqual(sub, '1') | ||
self.assertEqual(tenant, self.tenant_id) | ||
|
||
# Test get_raw_token with tenant | ||
creds, sub, tenant = profile.get_raw_token(resource='https://foo', tenant=self.tenant_id) | ||
|
||
self.assertEqual(creds[0], self.token_entry1['tokenType']) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we also add tests for MSI and cloud shell? (check exception) #Closed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. It is already in this file. |
||
self.assertEqual(creds[1], self.raw_token1) | ||
self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) | ||
mock_get_token.assert_called_with(mock.ANY, self.user1, self.tenant_id, 'https://foo') | ||
self.assertEqual(mock_get_token.call_count, 2) | ||
self.assertIsNone(sub) | ||
self.assertEqual(tenant, self.tenant_id) | ||
|
||
@mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) | ||
@mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_service_principal', autospec=True) | ||
def test_get_raw_token_for_sp(self, mock_get_token, mock_read_cred_file): | ||
|
@@ -744,6 +755,17 @@ def test_get_raw_token_for_sp(self, mock_get_token, mock_read_cred_file): | |
self.assertEqual(sub, '1') | ||
self.assertEqual(tenant, self.tenant_id) | ||
|
||
# Test get_raw_token with tenant | ||
creds, sub, tenant = profile.get_raw_token(resource='https://foo', tenant=self.tenant_id) | ||
|
||
self.assertEqual(creds[0], self.token_entry1['tokenType']) | ||
self.assertEqual(creds[1], self.raw_token1) | ||
self.assertEqual(creds[2]['expiresOn'], self.token_entry1['expiresOn']) | ||
mock_get_token.assert_called_with(mock.ANY, 'sp1', 'https://foo', self.tenant_id) | ||
self.assertEqual(mock_get_token.call_count, 2) | ||
self.assertIsNone(sub) | ||
self.assertEqual(tenant, self.tenant_id) | ||
|
||
@mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) | ||
@mock.patch('msrestazure.azure_active_directory.MSIAuthentication', autospec=True) | ||
def test_get_raw_token_msi_system_assigned(self, mock_msi_auth, mock_read_cred_file): | ||
|
@@ -765,12 +787,54 @@ def test_get_raw_token_msi_system_assigned(self, mock_msi_auth, mock_read_cred_f | |
mock_msi_auth.side_effect = MSRestAzureAuthStub | ||
|
||
# action | ||
cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource') | ||
cred, subscription_id, tenant_id = profile.get_raw_token(resource='http://test_resource') | ||
|
||
# assert | ||
self.assertEqual(subscription_id, test_subscription_id) | ||
self.assertEqual(cred[0], 'Bearer') | ||
self.assertEqual(cred[1], TestProfile.test_msi_access_token) | ||
self.assertEqual(subscription_id, test_subscription_id) | ||
self.assertEqual(tenant_id, test_tenant_id) | ||
|
||
# verify tenant shouldn't be specified for MSI account | ||
with self.assertRaisesRegexp(CLIError, "MSI"): | ||
cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource', tenant=self.tenant_id) | ||
|
||
@mock.patch('azure.cli.core._profile.in_cloud_console', autospec=True) | ||
@mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) | ||
@mock.patch('msrestazure.azure_active_directory.MSIAuthentication', autospec=True) | ||
def test_get_raw_token_in_cloud_console(self, mock_msi_auth, mock_read_cred_file, mock_in_cloud_console): | ||
mock_read_cred_file.return_value = [] | ||
mock_in_cloud_console.return_value = True | ||
|
||
# setup an existing msi subscription | ||
profile = Profile(cli_ctx=DummyCli(), storage={'subscriptions': None}, use_global_creds_cache=False, | ||
async_persist=False) | ||
test_subscription_id = '12345678-1bf0-4dda-aec3-cb9272f09590' | ||
test_tenant_id = '12345678-38d6-4fb2-bad9-b7b93a3e1234' | ||
msi_subscription = SubscriptionStub('/subscriptions/' + test_subscription_id, | ||
self.display_name1, self.state1, test_tenant_id) | ||
consolidated = profile._normalize_properties(self.user1, | ||
[msi_subscription], | ||
True) | ||
consolidated[0]['user']['cloudShellID'] = True | ||
profile._set_subscriptions(consolidated) | ||
|
||
mock_msi_auth.side_effect = MSRestAzureAuthStub | ||
|
||
# action | ||
cred, subscription_id, tenant_id = profile.get_raw_token(resource='http://test_resource') | ||
|
||
# assert | ||
self.assertEqual(subscription_id, test_subscription_id) | ||
self.assertEqual(cred[0], 'Bearer') | ||
self.assertEqual(cred[1], TestProfile.test_msi_access_token) | ||
self.assertEqual(subscription_id, test_subscription_id) | ||
self.assertEqual(tenant_id, test_tenant_id) | ||
|
||
# verify tenant shouldn't be specified for Cloud Shell account | ||
with self.assertRaisesRegexp(CLIError, 'Cloud Shell'): | ||
cred, subscription_id, _ = profile.get_raw_token(resource='http://test_resource', tenant=self.tenant_id) | ||
|
||
@mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) | ||
@mock.patch('azure.cli.core._profile.CredsCache.retrieve_token_for_user', autospec=True) | ||
|
@@ -1541,7 +1605,14 @@ def test_credscache_match_service_principal_correctly(self, _, mock_open_for_wri | |
|
||
# action and verify(we plant an exception to throw after the SP was found; so if the exception is thrown, | ||
# we know the matching did go through) | ||
self.assertRaises(ValueError, creds_cache.retrieve_token_for_service_principal, 'myapp', 'resource1', 'mytenant', False) | ||
self.assertRaises(ValueError, creds_cache.retrieve_token_for_service_principal, | ||
'myapp', 'resource1', 'mytenant', False) | ||
|
||
# tenant doesn't exactly match, but it still succeeds | ||
# before fully migrating to pytest and utilizing capsys fixture, use `pytest -o log_cli=True` to manually | ||
# verify the warning log | ||
self.assertRaises(ValueError, creds_cache.retrieve_token_for_service_principal, | ||
'myapp', 'resource1', 'mytenant2', False) | ||
|
||
@mock.patch('azure.cli.core._profile._load_tokens_from_file', autospec=True) | ||
@mock.patch('os.fdopen', autospec=True) | ||
|
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -59,27 +59,30 @@ def show_subscription(cmd, subscription=None, show_auth_for_sdk=None): | |||
print(json.dumps(profile.get_sp_auth_info(subscription), indent=2)) | ||||
|
||||
|
||||
def get_access_token(cmd, subscription=None, resource=None, resource_type=None): | ||||
''' | ||||
def get_access_token(cmd, subscription=None, resource=None, resource_type=None, tenant=None): | ||||
""" | ||||
get AAD token to access to a specified resource | ||||
:param resource: Azure resource endpoints. Default to Azure Resource Manager | ||||
:param resource-type: Name of Azure resource endpoints. Can be used instead of resource. | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please also add tenent in comment There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The doc string is overridden by
I'd prefer to omit here to eliminate duplication. |
||||
Use 'az cloud show' command for other Azure resources | ||||
''' | ||||
""" | ||||
if resource is None and resource_type is not None: | ||||
endpoints_attr_name = cloud_resource_type_mappings[resource_type] | ||||
resource = getattr(cmd.cli_ctx.cloud.endpoints, endpoints_attr_name) | ||||
else: | ||||
resource = (resource or cmd.cli_ctx.cloud.endpoints.active_directory_resource_id) | ||||
profile = Profile(cli_ctx=cmd.cli_ctx) | ||||
creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource) | ||||
return { | ||||
creds, subscription, tenant = profile.get_raw_token(subscription=subscription, resource=resource, tenant=tenant) | ||||
|
||||
result = { | ||||
'tokenType': creds[0], | ||||
'accessToken': creds[1], | ||||
'expiresOn': creds[2].get('expiresOn', 'N/A'), | ||||
'subscription': subscription, | ||||
'tenant': tenant | ||||
} | ||||
if subscription: | ||||
result['subscription'] = subscription | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems we changed previous behavior here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope. Previously there is no way for the user to get a token for a tenant, so this is a new feature rather than a breaking change. The previous behavior for |
||||
return result | ||||
|
||||
|
||||
def set_active_subscription(cmd, subscription): | ||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this error message pattern seems not align with existing pattern like "subscription shouldn't e specified when tenant is specified"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This expression is used by many other modules. You may Find All with "not both" to see other usage.