Skip to content

Commit

Permalink
Support azure cli credentials with multiple subscription_ids (#195)
Browse files Browse the repository at this point in the history
* feat: Support azure cli credentials with multiple `subscription_id`s

If the `subscription_id` is specified as module parameter or in the
environment then try to find that subscription in either the MSI (existing) or
CLI credentials (new). This patch brings those two scenarios in line.

* docs: Improve documentation on auth_source

* refactor: Move defaults up to class

Just trying to make the `__init__` fn a bit slimmer and easier to
reason about.

* refactor: Use python kwargs instead of passing dict

This is an isomorphic change, just using python syntax to accomplish
the exact same thing.

* refactor: Use ansible builtin `env_fallback` for `auth_source`

Ansible modules have a pattern for looking up a module parameter in
the environment with precedence of explicit param -> env -> default.
Use this pattern to simplify our code here.

This shouldn't change any behavior of `auth_source`, just using
standard ansible patterns to accomplish it.

* style: Split long line

No semantic change, just wrapping a long line to be a bit more
readable.

* refactor: helper fun _get_env

Somewhat frequently there is a lookup in the environment for the key that
matches a module parameter. This simple helper just encapsulates that
to make it a bit easier elsewhere-- lookup the same key in params,
credentials, env

* fix: typo in log message

Co-authored-by: Justin Ossevoort <[email protected]>
  • Loading branch information
UnwashedMeme and internetionals authored Sep 1, 2020
1 parent 48a5321 commit 7f78fb7
Show file tree
Hide file tree
Showing 2 changed files with 52 additions and 33 deletions.
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')
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

0 comments on commit 7f78fb7

Please sign in to comment.