From 263d82f8c4faa68bc72cd709c6e3079f75223240 Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Wed, 11 Sep 2024 10:46:45 +0200 Subject: [PATCH] Update AWX collection to use basic authentication Update AWX collection to use basic authentication when oauth token not provided, and when username and password provided. --- .../plugins/module_utils/controller_api.py | 147 +++++++++++++----- awx_collection/test/awx/test_build_url.py | 55 +++++++ 2 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 awx_collection/test/awx/test_build_url.py diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 541639306ac2..c2681a49afe9 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -12,6 +12,7 @@ from ansible.module_utils.six.moves.http_cookiejar import CookieJar from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode, quote from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError +from base64 import b64encode from socket import getaddrinfo, IPPROTO_TCP import time import re @@ -35,6 +36,8 @@ except ImportError: HAS_YAML = False +CONTROLLER_BASE_PATH_ENV_VAR = "CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX" + class ConfigFileException(Exception): pass @@ -79,6 +82,10 @@ class ControllerModule(AnsibleModule): version_checked = False error_callback = None warn_callback = None + apps_api_versions = { + "awx": "v2", + "gateway": "v1", + } def __init__(self, argument_spec=None, direct_params=None, error_callback=None, warn_callback=None, **kwargs): full_argspec = {} @@ -144,14 +151,15 @@ def __init__(self, argument_spec=None, direct_params=None, error_callback=None, except Exception as e: self.fail_json(msg="Unable to resolve controller_host ({1}): {0}".format(self.url.hostname, e)) - def build_url(self, endpoint, query_params=None): + def build_url(self, endpoint, query_params=None, app_key=None): # Make sure we start with /api/vX if not endpoint.startswith("/"): endpoint = "/{0}".format(endpoint) hostname_prefix = self.url_prefix.rstrip("/") - api_path = self.api_path() + api_path = self.api_path(app_key=app_key) + api_version = self.apps_api_versions.get(app_key, self.apps_api_versions.get("awx", "v2")) if not endpoint.startswith(hostname_prefix + api_path): - endpoint = hostname_prefix + f"{api_path}v2{endpoint}" + endpoint = hostname_prefix + f"{api_path}{api_version}{endpoint}" if not endpoint.endswith('/') and '?' not in endpoint: endpoint = "{0}/".format(endpoint) @@ -304,6 +312,9 @@ class ControllerAPIModule(ControllerModule): IDENTITY_FIELDS = {'users': 'username', 'workflow_job_template_nodes': 'identifier', 'instances': 'hostname'} ENCRYPTED_STRING = "$encrypted$" + # which app was used to create the oauth_token + oauth_token_app_key = None + def __init__(self, argument_spec, direct_params=None, error_callback=None, warn_callback=None, **kwargs): kwargs['supports_check_mode'] = True @@ -489,11 +500,13 @@ def make_request(self, method, endpoint, *args, **kwargs): # Authenticate to AWX (if we don't have a token and if not already done so) if not self.oauth_token and not self.authenticated: - # This method will set a cookie in the cookie jar for us and also an oauth_token + # This method will set a cookie in the cookie jar for us and also an oauth_token when possible self.authenticate(**kwargs) if self.oauth_token: # If we have a oauth token, we just use a bearer header headers['Authorization'] = 'Bearer {0}'.format(self.oauth_token) + elif self.username and self.password: + headers['Authorization'] = self._get_basic_authorization_header() if method in ['POST', 'PUT', 'PATCH']: headers.setdefault('Content-Type', 'application/json') @@ -604,28 +617,65 @@ def make_request(self, method, endpoint, *args, **kwargs): status_code = response.status return {'status_code': status_code, 'json': response_json} - def api_path(self): + def api_path(self, app_key=None): default_api_path = "/api/" - if self._COLLECTION_TYPE != "awx": - default_api_path = "/api/controller/" - prefix = getenv('CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX', default_api_path) + if self._COLLECTION_TYPE != "awx" or app_key is not None: + if app_key is None: + app_key = "controller" + + default_api_path = "/api/{0}/".format(app_key) + + prefix = default_api_path + if app_key is None or app_key == "controller": + # if the env variable exists use it only when app is not defined or controller + controller_base_path = getenv(CONTROLLER_BASE_PATH_ENV_VAR) + if controller_base_path: + self.warn( + "using controller base path from environment variable:" + " {0} = {1}".format(CONTROLLER_BASE_PATH_ENV_VAR, controller_base_path) + ) + prefix = controller_base_path + + if not prefix.startswith('/'): + prefix = "/{0}".format(prefix) + + if not prefix.endswith('/'): + prefix = "{0}/".format(prefix) + return prefix - def authenticate(self, **kwargs): + def _get_basic_authorization_header(self): + basic_credentials = b64encode("{0}:{1}".format(self.username, self.password).encode()).decode() + return "Basic {0}".format(basic_credentials) + + def _authenticate_with_basic_auth(self): + if self.username and self.password: + # use api url /api/v2/me to get current user info as a testing request + me_url = self.build_url("me").geturl() + self.session.open( + "GET", + me_url, + validate_certs=self.verify_ssl, + timeout=self.request_timeout, + follow_redirects=True, + headers={ + "Content-Type": "application/json", + "Authorization": self._get_basic_authorization_header(), + }, + ) + + def _authenticate_create_token(self, app_key=None): + # in case of failure and to give a chance to authenticate via other means, should not raise exceptions + # but only warnings if self.username and self.password: - # Attempt to get a token from /api/v2/tokens/ by giving it our username/password combo - # If we have a username and password, we need to get a session cookie login_data = { "description": "Automation Platform Controller Module Token", "application": None, "scope": "write", } - # Preserve URL prefix - endpoint = self.url_prefix.rstrip('/') + f'{self.api_path()}v2/tokens/' - # Post to the tokens endpoint with baisc auth to try and get a token - api_token_url = (self.url._replace(path=endpoint)).geturl() + api_token_url = self.build_url("tokens", app_key=app_key).geturl() try: response = self.session.open( 'POST', @@ -633,21 +683,16 @@ def authenticate(self, **kwargs): validate_certs=self.verify_ssl, timeout=self.request_timeout, follow_redirects=True, - force_basic_auth=True, - url_username=self.username, - url_password=self.password, data=dumps(login_data), - headers={'Content-Type': 'application/json'}, + headers={ + "Content-Type": "application/json", + "Authorization": self._get_basic_authorization_header(), + }, ) - except HTTPError as he: - try: - resp = he.read() - except Exception as e: - resp = 'unknown {0}'.format(e) - self.fail_json(msg='Failed to get token: {0}'.format(he), response=resp) - except (Exception) as e: - # Sanity check: Did the server send back some kind of internal error? - self.fail_json(msg='Failed to get token: {0}'.format(e)) + + except Exception as exp: + self.warn("url: {0} - Failed to get token: {1}".format(api_token_url, exp)) + return token_response = None try: @@ -655,10 +700,37 @@ def authenticate(self, **kwargs): response_json = loads(token_response) self.oauth_token_id = response_json['id'] self.oauth_token = response_json['token'] - except (Exception) as e: - self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{'response': token_response}) + # set the app that received the token create request, this is needed when removing the token at logout + self.oauth_token_app_key = app_key + except Exception as exp: + self.warn( + "url: {0} - Failed to extract token information from login response: {1}, response: {2}".format( + api_token_url, exp, token_response, + ) + ) + return + + return None + + def authenticate(self, **kwargs): + # As a temporary solution for version 4.6 try to get a token by using basic authentication from: + # /api/gateway/v1/tokens/ when app_key is gateway + # /api/v2/tokens/ when app_key is None and _COLLECTION_TYPE = "awx" + # /api/controller/v2/tokens/ when app_key is None and _COLLECTION_TYPE != "awx" + for app_key in ["gateway", None]: + # to give a chance to authenticate via basic authentication in case of failure, + # _authenticate_create_token, should not raise exception but only warnings, + self._authenticate_create_token(app_key=app_key) + if self.oauth_token: + break + + if not self.oauth_token: + # if not having an oauth_token and when collection_type is awx try to login with basic authentication + try: + self._authenticate_with_basic_auth() + except Exception as exp: + self.fail_json(msg='Failed to get user info: {0}'.format(exp)) - # If we have neither of these, then we can try un-authenticated access self.authenticated = True def delete_if_needed(self, existing_item, item_type=None, on_delete=None, auto_exit=True): @@ -1011,8 +1083,10 @@ def logout(self): if self.authenticated and self.oauth_token_id: # Attempt to delete our current token from /api/v2/tokens/ # Post to the tokens endpoint with baisc auth to try and get a token - endpoint = self.url_prefix.rstrip('/') + f'{self.api_path()}v2/tokens/{self.oauth_token_id}/' - api_token_url = (self.url._replace(path=endpoint, query=None)).geturl() # in error cases, fail_json exists before exception handling + api_token_url = self.build_url( + "tokens/{0}/".format(self.oauth_token_id), + app_key=self.oauth_token_app_key, + ).geturl() try: self.session.open( @@ -1021,11 +1095,12 @@ def logout(self): validate_certs=self.verify_ssl, timeout=self.request_timeout, follow_redirects=True, - force_basic_auth=True, - url_username=self.username, - url_password=self.password, + headers={ + "Authorization": self._get_basic_authorization_header(), + } ) self.oauth_token_id = None + self.oauth_token = None self.authenticated = False except HTTPError as he: try: diff --git a/awx_collection/test/awx/test_build_url.py b/awx_collection/test/awx/test_build_url.py new file mode 100644 index 000000000000..262c8f01b1c6 --- /dev/null +++ b/awx_collection/test/awx/test_build_url.py @@ -0,0 +1,55 @@ +from __future__ import absolute_import, division, print_function + +import os +from unittest import mock + +__metaclass__ = type + +import pytest + + +@pytest.mark.parametrize( + "collection_type, env_prefix, controller_host, app_key, endpoint, expected", + [ + # without CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX env variable + ["awx", None, "https://localhost:8043", None, "jobs", "https://localhost:8043/api/v2/jobs/"], + ["awx", None, "https://localhost:8043", None, "jobs/209", "https://localhost:8043/api/v2/jobs/209/"], + ["awx", None, "https://localhost:8043", None, "organizations", "https://localhost:8043/api/v2/organizations/"], + ["awx", None, "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"], + ["awx", None, "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"], + ["awx", None, "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"], + ["awx", None, "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"], + ["controller", None, "https://localhost", None, "jobs", "https://localhost/api/controller/v2/jobs/"], + ["controller", None, "https://localhost", None, "jobs/209", "https://localhost/api/controller/v2/jobs/209/"], + ["controller", None, "https://localhost", None, "organizations", "https://localhost/api/controller/v2/organizations/"], + ["controller", None, "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"], + ["controller", None, "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"], + ["controller", None, "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"], + ["controller", None, "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"], + # with CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX env variable + ["awx", "api/controller", "https://localhost", None, "jobs", "https://localhost/api/controller/v2/jobs/"], + ["awx", "api/controller", "https://localhost", None, "jobs/209", "https://localhost/api/controller/v2/jobs/209/"], + ["awx", "api/controller", "https://localhost", None, "organizations", "https://localhost/api/controller/v2/organizations/"], + ["awx", "api/controller", "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"], + ["awx", "api/controller", "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"], + ["awx", "api/controller", "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"], + ["awx", "api/controller", "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"], + ["controller", "api/controller", "https://localhost", None, "jobs", "https://localhost/api/controller/v2/jobs/"], + ["controller", "api/controller", "https://localhost", None, "jobs/209", "https://localhost/api/controller/v2/jobs/209/"], + ["controller", "api/controller", "https://localhost", "controller", "jobs", "https://localhost/api/controller/v2/jobs/"], + ["controller", "api/controller", "https://localhost", "controller", "jobs/1", "https://localhost/api/controller/v2/jobs/1/"], + ["controller", "api/controller", "https://localhost", "gateway", "tokens", "https://localhost/api/gateway/v1/tokens/"], + ["controller", "api/controller", "https://localhost", "gateway", "tokens/199", "https://localhost/api/gateway/v1/tokens/199/"], + ] +) +def test_controller_api_build_url(collection_import, collection_type, env_prefix, controller_host, app_key, endpoint, expected): + controller_api_class = collection_import('plugins.module_utils.controller_api').ControllerAPIModule + controller_api = controller_api_class(argument_spec={}, direct_params=dict(controller_host=controller_host)) + controller_api._COLLECTION_TYPE = collection_type + if env_prefix: + with mock.patch.dict(os.environ, {"CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX": env_prefix}): + request_url = controller_api.build_url(endpoint, app_key=app_key).geturl() + else: + request_url = controller_api.build_url(endpoint, app_key=app_key).geturl() + + assert request_url == expected