Skip to content
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

Support azure cli credentials with multiple subscription_ids #195

Merged
11 changes: 7 additions & 4 deletions plugins/doc_fragments/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,19 @@ class ModuleDocFragment(object):
auth_source:
description:
- Controls the source of the credentials to use for authentication.
- If not specified, ANSIBLE_AZURE_AUTH_SOURCE environment variable will be used and default to C(auto) if variable is not defined.
- C(auto) will follow the default precedence of module parameters -> environment variables -> default profile in credential file
C(~/.azure/credentials).
- When set to C(cli), the credentials will be sources from the default Azure CLI profile.
- Can also be set via the C(ANSIBLE_AZURE_AUTH_SOURCE) environment variable.
- When set to C(auto) (the default) the precedence is module parameters -> C(env) -> C(credential_file) -> C(cli).
- When set to C(env), the credentials will be read from the environment variables
- When set to C(credential_file), it will read the profile from C(~/.azure/credentials).
- When set to C(cli), the credentials will be sources from the Azure CLI profile. C(subscription_id) or the environment variable
C(AZURE_SUBSCRIPTION_ID) can be used to identify the subscription ID if more than one is present otherwise the default
az cli subscription is used.
- When set to C(msi), the host machine must be an azure resource with an enabled MSI extension. C(subscription_id) or the
environment variable C(AZURE_SUBSCRIPTION_ID) can be used to identify the subscription ID if the resource is granted
access to more than one subscription, otherwise the first subscription is chosen.
- The C(msi) was added in Ansible 2.6.
type: str
default: auto
choices:
- auto
- cli
Expand Down
74 changes: 45 additions & 29 deletions plugins/module_utils/azure_rm_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
pass
from os.path import expanduser

from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils.basic import \
AnsibleModule, missing_required_lib, env_fallback

try:
from ansible.module_utils.ansible_release import __version__ as ANSIBLE_VERSION
except Exception:
Expand All @@ -32,7 +34,9 @@
AZURE_COMMON_ARGS = dict(
auth_source=dict(
type='str',
choices=['auto', 'cli', 'env', 'credential_file', 'msi']
choices=['auto', 'cli', 'env', 'credential_file', 'msi'],
fallback=(env_fallback, ['ANSIBLE_AZURE_AUTH_SOURCE']),
default="auto"
),
profile=dict(type='str'),
subscription_id=dict(type='str'),
Expand Down Expand Up @@ -276,7 +280,7 @@ def default_api_version(self):

try:
from azure.cli.core.util import CLIError
from azure.common.credentials import get_azure_cli_credentials, get_cli_profile
from azure.common.credentials import get_cli_profile
from azure.common.cloud import get_cli_active_cloud
except ImportError:
HAS_AZURE_CLI_CORE = False
Expand Down Expand Up @@ -1234,24 +1238,33 @@ class AzureRMAuthException(Exception):


class AzureRMAuth(object):
def __init__(self, auth_source='auto', profile=None, subscription_id=None, client_id=None, secret=None,
_cloud_environment = None
_adfs_authority_url = None

def __init__(self, auth_source=None, profile=None, subscription_id=None, client_id=None, secret=None,
tenant=None, ad_user=None, password=None, cloud_environment='AzureCloud', cert_validation_mode='validate',
api_profile='latest', adfs_authority_url=None, fail_impl=None, is_ad_resource=False, **kwargs):

if fail_impl:
self._fail_impl = fail_impl
else:
self._fail_impl = self._default_fail_impl

self._cloud_environment = None
self._adfs_authority_url = None
self.is_ad_resource = is_ad_resource

# authenticate
self.credentials = self._get_credentials(
dict(auth_source=auth_source, profile=profile, subscription_id=subscription_id, client_id=client_id, secret=secret,
tenant=tenant, ad_user=ad_user, password=password, cloud_environment=cloud_environment,
cert_validation_mode=cert_validation_mode, api_profile=api_profile, adfs_authority_url=adfs_authority_url))
auth_source=auth_source,
profile=profile,
subscription_id=subscription_id,
client_id=client_id,
secret=secret,
tenant=tenant,
ad_user=ad_user,
password=password,
cloud_environment=cloud_environment,
cert_validation_mode=cert_validation_mode,
api_profile=api_profile,
adfs_authority_url=adfs_authority_url)

if not self.credentials:
if HAS_AZURE_CLI_CORE:
Expand All @@ -1262,8 +1275,10 @@ def __init__(self, auth_source='auto', profile=None, subscription_id=None, clien
"define a profile in ~/.azure/credentials, or install Azure CLI and log in (`az login`).")

# cert validation mode precedence: module-arg, credential profile, env, "validate"
self._cert_validation_mode = cert_validation_mode or self.credentials.get('cert_validation_mode') or \
os.environ.get('AZURE_CERT_VALIDATION_MODE') or 'validate'
self._cert_validation_mode = cert_validation_mode or \
self.credentials.get('cert_validation_mode') or \
self._get_env('cert_validation_mode') or \
'validate'

if self._cert_validation_mode not in ['validate', 'ignore']:
self.fail('invalid cert_validation_mode: {0}'.format(self._cert_validation_mode))
Expand Down Expand Up @@ -1353,6 +1368,10 @@ def fail(self, msg, exception=None, **kwargs):
def _default_fail_impl(self, msg, exception=None, **kwargs):
raise AzureRMAuthException(msg)

def _get_env(self, module_key, default=None):
"Read envvar matching module parameter"
return os.environ.get(AZURE_CREDENTIAL_ENV_MAPPING[module_key], default)

def _get_profile(self, profile="default"):
path = expanduser("~/.azure/credentials")
try:
Expand All @@ -1373,10 +1392,9 @@ def _get_profile(self, profile="default"):

return None

def _get_msi_credentials(self, subscription_id_param=None, **kwargs):
client_id = kwargs.get('client_id', None)
def _get_msi_credentials(self, subscription_id=None, client_id=None, **kwargs):
credentials = MSIAuthentication(client_id=client_id)
subscription_id = subscription_id_param or os.environ.get(AZURE_CREDENTIAL_ENV_MAPPING['subscription_id'], None)
subscription_id = subscription_id or self._get_env('subscription_id')
if not subscription_id:
try:
# use the first subscription of the MSI
Expand All @@ -1391,10 +1409,13 @@ def _get_msi_credentials(self, subscription_id_param=None, **kwargs):
'subscription_id': subscription_id
}

def _get_azure_cli_credentials(self, resource=None):
def _get_azure_cli_credentials(self, subscription_id=None, resource=None):
if self.is_ad_resource:
resource = 'https://graph.windows.net/'
credentials, subscription_id = get_azure_cli_credentials(resource)
subscription_id = subscription_id or self._get_env('subscription_id')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@UnwashedMeme Why do you catch the subscription_id of the environment variable here? auth_source='cli' uses the credentials of 'az loggin', so the environment variable subscription should not be used in this function, please update. Thank you!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you catch the subscription_id of the environment variable here?

Because that is a valid way of providing data to these modules and this PR reduces the number of cases in which a user provided subscription_id is unexpectedly ignored.

More broadly "The authentication mechanism (service principal, msi, cli) and parameter passing convention (module parameters, environment variables, profile file) should be as loosely coupled as possible."

I believe you have been looking at this as "There currently several distinct parameter sets that are entirely separate depending upon which auth_source is specified." I don't believe that is true in existing code except by accident and it certainly isn't stated in the module documentation.

The only places subscription_id should be required is when we don't have a mechanism for looking it up associated with the authentication. The service principal authentication code does not use the subscription_id. But since we need to have a subscription_id when creating the mgmt client it is required. When not set explicitly, some mechanisms like MSI and CLI can provide a default. If the module user provides a subscription id through any mechanism it should be respected.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but if you assign the subscription_id of the environment variable here, then auth_source= cli will not use the credentials of 'az logging', which is contrary to the designed auth_source parameter.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I'm think here, if you set ’auth_source= cli‘, then you're going to use the 'az login' credentials, and if you can't catch the credentials or if the credentials are wrong, you're going to throw an exception,' az Login 'credentials have a problem. Thank you very much!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you assign the subscription_id of the environment variable here, then auth_source= cli will not use the credentials of 'az logging'

This is not a problem because subscription_id is not part of the credentials from az login. Please recall that the credentials from az login can grant access to many subscriptions. The subscription_id module parameter or environment variable just allows the user to specify which one this module should actually target.

As with any auth_source, if the user specifies a subscription_id that the credentials aren't authorized for it won't work.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll also update my description, the subscription_id here should get the subscription_id of the playbook parameter, not the one in the environment variable. Thank you very much!

profile = get_cli_profile()
credentials, subscription_id, tenant = profile.get_login_credentials(
subscription_id=subscription_id, resource=resource)
cloud_environment = get_cli_active_cloud()

cli_credentials = {
Expand All @@ -1418,30 +1439,25 @@ def _get_env_credentials(self):

return None

# TODO: use explicit kwargs instead of intermediate dict
def _get_credentials(self, params):
def _get_credentials(self, auth_source=None, **params):
# Get authentication credentials.
self.log('Getting credentials')

arg_credentials = dict()
for attribute, env_variable in AZURE_CREDENTIAL_ENV_MAPPING.items():
arg_credentials[attribute] = params.get(attribute, None)

auth_source = params.get('auth_source', None)
if not auth_source:
auth_source = os.environ.get('ANSIBLE_AZURE_AUTH_SOURCE', 'auto')

if auth_source == 'msi':
self.log('Retrieving credenitals from MSI')
return self._get_msi_credentials(arg_credentials['subscription_id'], client_id=params.get('client_id', None))
self.log('Retrieving credentials from MSI')
return self._get_msi_credentials(subscription_id=params.get('subscription_id'), client_id=params.get('client_id'))

if auth_source == 'cli':
if not HAS_AZURE_CLI_CORE:
self.fail(msg=missing_required_lib('azure-cli', reason='for `cli` auth_source'),
exception=HAS_AZURE_CLI_CORE_EXC)
try:
self.log('Retrieving credentials from Azure CLI profile')
cli_credentials = self._get_azure_cli_credentials()
cli_credentials = self._get_azure_cli_credentials(subscription_id=params.get('subscription_id'))
return cli_credentials
except CLIError as err:
self.fail("Azure CLI profile cannot be loaded - {0}".format(err))
Expand All @@ -1457,14 +1473,14 @@ def _get_credentials(self, params):
default_credentials = self._get_profile(profile)
return default_credentials

# auto, precedence: module parameters -> environment variables -> default profile in ~/.azure/credentials
# auto, precedence: module parameters -> environment variables -> default profile in ~/.azure/credentials -> azure cli
# try module params
if arg_credentials['profile'] is not None:
self.log('Retrieving credentials with profile parameter.')
credentials = self._get_profile(arg_credentials['profile'])
return credentials

if arg_credentials['subscription_id']:
if arg_credentials['client_id'] or arg_credentials['ad_user']:
self.log('Received credentials from parameters.')
return arg_credentials

Expand All @@ -1483,7 +1499,7 @@ def _get_credentials(self, params):
try:
if HAS_AZURE_CLI_CORE:
self.log('Retrieving credentials from AzureCLI profile')
cli_credentials = self._get_azure_cli_credentials()
cli_credentials = self._get_azure_cli_credentials(subscription_id=params.get('subscription_id'))
return cli_credentials
except CLIError as ce:
self.log('Error getting AzureCLI profile credentials - {0}'.format(ce))
Expand Down