diff --git a/awx_collection/plugins/doc_fragments/auth.py b/awx_collection/plugins/doc_fragments/auth.py index 1fcde939df..272ca0da67 100644 --- a/awx_collection/plugins/doc_fragments/auth.py +++ b/awx_collection/plugins/doc_fragments/auth.py @@ -34,13 +34,15 @@ options: aliases: [ tower_password , aap_password ] aap_token: description: - - The OAuth token to use. + - The OAuth token to use, sent as a Bearer token in the Authorization header. + - When connecting through the AAP gateway, use a token issued by the gateway. - This value can be in one of two formats. - A string which is the token itself. (i.e. bqV5txm97wqJqtkxlMkhQz0pKhRMMX) - - A dictionary structure as returned by the token module. + - A dictionary structure as set as a fact by the M(ansible.platform.token) module. - If value not set, will try environment variable C(CONTROLLER_OAUTH_TOKEN) and then config files type: raw version_added: "3.7.0" + aliases: [ controller_oauthtoken, tower_oauthtoken ] validate_certs: description: - Whether to allow insecure connections to AWX. diff --git a/awx_collection/plugins/doc_fragments/auth_plugin.py b/awx_collection/plugins/doc_fragments/auth_plugin.py index 15b78f949f..846659d7a4 100644 --- a/awx_collection/plugins/doc_fragments/auth_plugin.py +++ b/awx_collection/plugins/doc_fragments/auth_plugin.py @@ -42,13 +42,23 @@ options: alternatives: 'TOWER_PASSWORD, AAP_PASSWORD' aap_token: description: - - The OAuth token to use. + - The OAuth token to use, sent as a Bearer token in the Authorization header. + - When connecting through the AAP gateway, use a token issued by the gateway. env: - - name: AAP_TOKEN + - name: CONTROLLER_OAUTH_TOKEN deprecated: collection_name: 'awx.awx' version: '4.0.0' why: Collection name change + alternatives: 'AAP_TOKEN' + - name: TOWER_OAUTH_TOKEN + deprecated: + collection_name: 'awx.awx' + version: '4.0.0' + why: Collection name change + alternatives: 'AAP_TOKEN' + - name: AAP_TOKEN + aliases: [ controller_oauthtoken, tower_oauthtoken ] verify_ssl: description: - Specify whether Ansible should verify the SSL certificate of the controller host. diff --git a/awx_collection/plugins/module_utils/awxkit.py b/awx_collection/plugins/module_utils/awxkit.py index 5029d44526..b296f6a2df 100644 --- a/awx_collection/plugins/module_utils/awxkit.py +++ b/awx_collection/plugins/module_utils/awxkit.py @@ -34,7 +34,10 @@ class ControllerAWXKitModule(ControllerModule): def authenticate(self): try: - self.connection.login(username=self.username, password=self.password) + if self.aap_token: + self.connection.session.headers['Authorization'] = 'Bearer {0}'.format(self.aap_token) + else: + self.connection.login(username=self.username, password=self.password) self.authenticated = True except Exception: self.fail_json("Failed to authenticate") diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 8d033f9afb..2a2d4604ac 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -99,6 +99,7 @@ class ControllerModule(AnsibleModule): aap_token=dict( type='raw', no_log=True, + aliases=['controller_oauthtoken', 'tower_oauthtoken'], required=False, fallback=(env_fallback, ['CONTROLLER_OAUTH_TOKEN', 'TOWER_OAUTH_TOKEN', 'AAP_TOKEN']) ), @@ -118,10 +119,12 @@ class ControllerModule(AnsibleModule): 'request_timeout': 'request_timeout', 'max_retries': 'max_retries', 'retry_backoff_factor': 'retry_backoff_factor', + 'aap_token': 'aap_token', } host = '127.0.0.1' username = None password = None + aap_token = None verify_ssl = True request_timeout = 10 max_retries = 5 @@ -160,6 +163,8 @@ class ControllerModule(AnsibleModule): if direct_value is not None: setattr(self, short_param, direct_value) + self._parse_aap_token() + # Perform some basic validation if not self.host.startswith(("https://", "http://")): # NOSONAR self.host = "https://{0}".format(self.host) @@ -186,6 +191,15 @@ class ControllerModule(AnsibleModule): except Exception as e: self.fail_json(msg="Unable to resolve controller_host ({1}): {0}".format(self.url.hostname, e)) + def _parse_aap_token(self): + # aap_token can be the token string itself, or the dict that the + # ansible.platform.token module sets as the aap_token fact + if isinstance(self.aap_token, dict): + if 'token' in self.aap_token: + self.aap_token = self.aap_token['token'] + else: + self.fail_json(msg="The provided dict in aap_token did not properly contain the token entry") + def build_url(self, endpoint, query_params=None, app_key=None): # Make sure we start with /api/vX if not endpoint.startswith("/"): @@ -572,12 +586,7 @@ class ControllerAPIModule(ControllerModule): # Extract the headers, this will be used in a couple of places headers = kwargs.get('headers', {}) - # Authenticate to AWX (if not already done so) - if not self.authenticated: - # This method will set a cookie in the cookie jar for us - self.authenticate(**kwargs) - - headers['Authorization'] = self._get_basic_authorization_header() + headers['Authorization'] = self._get_authorization_header(**kwargs) if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') @@ -761,6 +770,19 @@ class ControllerAPIModule(ControllerModule): return prefix + def _get_authorization_header(self, **kwargs): + if self.aap_token: + # A token (e.g. issued by the AAP gateway) is validated by the server on + # every request, so no login round-trip is needed + return 'Bearer {0}'.format(self.aap_token) + + # Authenticate to AWX (if not already done so) + if not self.authenticated: + # This method will set a cookie in the cookie jar for us + self.authenticate(**kwargs) + + return self._get_basic_authorization_header() + def _get_basic_authorization_header(self): basic_credentials = b64encode("{0}:{1}".format(self.username, self.password).encode()).decode() return "Basic {0}".format(basic_credentials) diff --git a/awx_collection/test/awx/test_token_auth.py b/awx_collection/test/awx/test_token_auth.py new file mode 100644 index 0000000000..5400d6f7a5 --- /dev/null +++ b/awx_collection/test/awx/test_token_auth.py @@ -0,0 +1,103 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json + +import pytest + +from ansible.module_utils import basic +from ansible.module_utils.common.text.converters import to_bytes +from requests.models import Response +from unittest import mock + + +def getheader(self, header_name, default): + return default + + +def read(self): + return json.dumps({}) + + +def status(self): + return 200 + + +def make_recorder(): + """Build a mock for Request.open that records every call made through it.""" + calls = [] + + def opener(self, method, url, **kwargs): + calls.append({'method': method, 'url': url, 'headers': kwargs.get('headers') or {}}) + r = Response() + r.getheader = getheader.__get__(r) + r.read = read.__get__(r) + r.status = status.__get__(r) + return r + + return opener, calls + + +def make_module(collection_import, module_args, **kwargs): + ControllerAPIModule = collection_import('plugins.module_utils.controller_api').ControllerAPIModule + cli_data = {'ANSIBLE_MODULE_ARGS': module_args} + # patch the cached args directly: AnsibleModule caches sys.argv parsing in + # basic._ANSIBLE_ARGS, so patching sys.argv would leak args between tests + with mock.patch.object(basic, '_ANSIBLE_ARGS', to_bytes(json.dumps(cli_data))): + return ControllerAPIModule(argument_spec=dict(), **kwargs) + + +@pytest.mark.parametrize( + 'token_value', + [ + 'a-token-string', + {'token': 'a-token-string', 'id': 1}, # the aap_token fact set by ansible.platform.token + ], + ids=['string', 'dict'], +) +def test_aap_token_sends_bearer_header(collection_import, token_value): + module = make_module(collection_import, {'aap_token': token_value}) + assert module.aap_token == 'a-token-string' + + opener, calls = make_recorder() + with mock.patch('ansible.module_utils.urls.Request.open', new=opener): + module.get_endpoint('ping') + + assert len(calls) == 1, calls + assert calls[0]['headers']['Authorization'] == 'Bearer a-token-string' + # a token needs no login round-trip + assert module.authenticated is False + + +@pytest.mark.parametrize('param', ['controller_oauthtoken', 'tower_oauthtoken']) +def test_aap_token_legacy_aliases(collection_import, param): + module = make_module(collection_import, {param: 'legacy-token'}) + assert module.aap_token == 'legacy-token' + + +def test_aap_token_dict_without_token_entry_fails(collection_import): + errors = [] + + def error_callback(**kwargs): + errors.append(kwargs) + raise SystemExit(1) + + with pytest.raises(SystemExit): + make_module(collection_import, {'aap_token': {'id': 1}}, error_callback=error_callback) + + assert 'did not properly contain the token entry' in errors[0]['msg'] + + +def test_no_token_falls_back_to_basic_auth(collection_import): + module = make_module(collection_import, {'controller_username': 'admin', 'controller_password': 'secret'}) + + opener, calls = make_recorder() + with mock.patch('ansible.module_utils.urls.Request.open', new=opener): + module.get_endpoint('ping') + + # first call is the authentication probe, second is the actual request + assert len(calls) == 2, calls + for call in calls: + assert call['headers']['Authorization'].startswith('Basic '), call + assert module.authenticated is True