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 Sep 27, 2024
1 parent 5b7a050 commit 4ce2947
Showing 1 changed file with 90 additions and 31 deletions.
121 changes: 90 additions & 31 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 Down Expand Up @@ -79,6 +80,11 @@ class ControllerModule(AnsibleModule):
version_checked = False
error_callback = None
warn_callback = None
apps_api_versions = {
"awx": "v2",
"controller": "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 +150,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 +311,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 +499,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 +616,106 @@ 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/"
if app_key is None:
app_key = "controller"

default_api_path = "/api/{0}/".format(app_key)

prefix = getenv('CONTROLLER_OPTIONAL_API_URLPATTERN_PREFIX', default_api_path)
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
# Attempt to get a token by using basic authentication from:
# /api/v2/tokens/ when app_key is None
# /api/gateway/v1/tokens/ when app_key is gateway
# /api/controller/v2/tokens/ when app_key is controller
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()

Check warning on line 665 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#L665

Added line #L665 was not covered by tests
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("app: {0} - Failed to get token: {1}".format(app_key, exp))
return

Check warning on line 682 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#L680-L682

Added lines #L680 - L682 were not covered by tests

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 693 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#L691-L693

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

Check warning on line 698 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#L698

Added line #L698 was not covered by tests

return None

def authenticate(self, **kwargs):
if self._COLLECTION_TYPE != "awx":
# when collection type is not awx try to create an oauth_token via gateway first and then controller
for app_key in ["gateway", "controller"]:
# 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

Check warning on line 710 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#L710

Added line #L710 was not covered by tests

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 717 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#L716-L717

Added lines #L716 - L717 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 +1068,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 = "{0}/{1}/".format(

Check warning on line 1071 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#L1071

Added line #L1071 was not covered by tests
self.build_url("tokens", app_key=self.oauth_token_app_key).geturl(),
self.oauth_token_id
)

try:
self.session.open(
Expand Down

0 comments on commit 4ce2947

Please sign in to comment.