Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update AWX collection to use basic authentication #15554

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
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 @@
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 @@

# 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()

Check warning on line 509 in awx_collection/plugins/module_utils/controller_api.py

View check run for this annotation

Codecov / codecov/patch

awx_collection/plugins/module_utils/controller_api.py#L509

Added line #L509 was not covered by tests

if method in ['POST', 'PUT', 'PATCH']:
headers.setdefault('Content-Type', 'application/json')
Expand Down Expand Up @@ -604,61 +617,120 @@
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(

Check warning on line 656 in awx_collection/plugins/module_utils/controller_api.py

View check run for this annotation

Codecov / codecov/patch

awx_collection/plugins/module_utils/controller_api.py#L655-L656

Added lines #L655 - L656 were not covered by tests
"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(

Check warning on line 706 in awx_collection/plugins/module_utils/controller_api.py

View check run for this annotation

Codecov / codecov/patch

awx_collection/plugins/module_utils/controller_api.py#L705-L706

Added lines #L705 - L706 were not covered by tests
"url: {0} - Failed to extract token information from login response: {1}, response: {2}".format(
api_token_url, exp, token_response,
)
)
return

Check warning on line 711 in awx_collection/plugins/module_utils/controller_api.py

View check run for this annotation

Codecov / codecov/patch

awx_collection/plugins/module_utils/controller_api.py#L711

Added line #L711 was not covered by tests

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))

Check warning on line 732 in awx_collection/plugins/module_utils/controller_api.py

View check run for this annotation

Codecov / codecov/patch

awx_collection/plugins/module_utils/controller_api.py#L731-L732

Added lines #L731 - L732 were not covered by tests

# 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 @@
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 @@
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this future import is only useful under python 2


import os
from unittest import mock
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In pytest, it's customary to use the built-in monkeypatch fixture or mocker provided by the pytest-mock plugin.


__metaclass__ = type

import pytest


@pytest.mark.parametrize(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a matrix of repeated things. It is possible to produce it using several stacked parametrize decorators.

"collection_type, env_prefix, controller_host, app_key, endpoint, expected",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pro tip: this can be an interable of strings which typically reads better.

[
# 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}):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For conditional CM, there's a from contextlib import nullcontext which can make this more elegant.
Additionally, the builtin fixture can give you a monkeypatch.setenv() w/o needing to import anything or integrate third parties.

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
Loading