Skip to content

Commit

Permalink
Update AWX collection to use basic authentication (ansible#15554)
Browse files Browse the repository at this point in the history
Update AWX collection to use basic authentication when oauth token not provided,
and when username and password provided.
  • Loading branch information
ldjebran authored Oct 8, 2024
1 parent ece21b1 commit 579c2b7
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 36 deletions.
147 changes: 111 additions & 36 deletions awx_collection/plugins/module_utils/controller_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,8 @@
except ImportError:
HAS_YAML = False

CONTROLLER_BASE_PATH_ENV_VAR = "CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX"


class ConfigFileException(Exception):
pass
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -604,61 +617,120 @@ 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',
api_token_url,
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:
token_response = response.read()
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):
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions awx_collection/test/awx/test_build_url.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 579c2b7

Please sign in to comment.