Skip to content

Commit

Permalink
Update AWX collection to use basic authentication
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 committed Oct 3, 2024
1 parent a1ad320 commit 4bd34d9
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 4bd34d9

Please sign in to comment.