400 lines
18 KiB
Python
400 lines
18 KiB
Python
# Copyright (c) 2018 Ansible, Inc.
|
|
# All Rights Reserved.
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import operator
|
|
from collections import OrderedDict
|
|
|
|
from django.conf import settings
|
|
from django.utils.encoding import smart_text
|
|
from django.utils.decorators import method_decorator
|
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
|
from django.template.loader import render_to_string
|
|
from django.utils.translation import ugettext_lazy as _
|
|
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
|
|
import requests
|
|
|
|
from awx.api.generics import APIView
|
|
from awx.conf.registry import settings_registry
|
|
from awx.main.analytics import all_collectors
|
|
from awx.main.ha import is_ha_environment
|
|
from awx.main.utils import (
|
|
get_awx_version,
|
|
get_ansible_version,
|
|
get_custom_venv_choices,
|
|
to_python_boolean,
|
|
)
|
|
from awx.main.utils.licensing import validate_entitlement_manifest
|
|
from awx.api.versioning import reverse, drf_reverse
|
|
from awx.main.constants import PRIVILEGE_ESCALATION_METHODS
|
|
from awx.main.models import (
|
|
Project,
|
|
Organization,
|
|
Instance,
|
|
InstanceGroup,
|
|
JobTemplate,
|
|
)
|
|
from awx.main.utils import set_environ
|
|
|
|
logger = logging.getLogger('awx.api.views.root')
|
|
|
|
|
|
class ApiRootView(APIView):
|
|
|
|
permission_classes = (AllowAny,)
|
|
name = _('REST API')
|
|
versioning_class = None
|
|
swagger_topic = 'Versioning'
|
|
|
|
@method_decorator(ensure_csrf_cookie)
|
|
def get(self, request, format=None):
|
|
''' List supported API versions '''
|
|
|
|
v2 = reverse('api:api_v2_root_view', kwargs={'version': 'v2'})
|
|
data = OrderedDict()
|
|
data['description'] = _('AWX REST API')
|
|
data['current_version'] = v2
|
|
data['available_versions'] = dict(v2 = v2)
|
|
data['oauth2'] = drf_reverse('api:oauth_authorization_root_view')
|
|
data['custom_logo'] = settings.CUSTOM_LOGO
|
|
data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO
|
|
data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE
|
|
return Response(data)
|
|
|
|
|
|
class ApiOAuthAuthorizationRootView(APIView):
|
|
|
|
permission_classes = (AllowAny,)
|
|
name = _("API OAuth 2 Authorization Root")
|
|
versioning_class = None
|
|
swagger_topic = 'Authentication'
|
|
|
|
def get(self, request, format=None):
|
|
data = OrderedDict()
|
|
data['authorize'] = drf_reverse('api:authorize')
|
|
data['token'] = drf_reverse('api:token')
|
|
data['revoke_token'] = drf_reverse('api:revoke-token')
|
|
return Response(data)
|
|
|
|
|
|
class ApiVersionRootView(APIView):
|
|
|
|
permission_classes = (AllowAny,)
|
|
swagger_topic = 'Versioning'
|
|
|
|
def get(self, request, format=None):
|
|
''' List top level resources '''
|
|
data = OrderedDict()
|
|
data['ping'] = reverse('api:api_v2_ping_view', request=request)
|
|
data['instances'] = reverse('api:instance_list', request=request)
|
|
data['instance_groups'] = reverse('api:instance_group_list', request=request)
|
|
data['config'] = reverse('api:api_v2_config_view', request=request)
|
|
data['settings'] = reverse('api:setting_category_list', request=request)
|
|
data['me'] = reverse('api:user_me_list', request=request)
|
|
data['dashboard'] = reverse('api:dashboard_view', request=request)
|
|
data['organizations'] = reverse('api:organization_list', request=request)
|
|
data['users'] = reverse('api:user_list', request=request)
|
|
data['projects'] = reverse('api:project_list', request=request)
|
|
data['project_updates'] = reverse('api:project_update_list', request=request)
|
|
data['teams'] = reverse('api:team_list', request=request)
|
|
data['credentials'] = reverse('api:credential_list', request=request)
|
|
data['credential_types'] = reverse('api:credential_type_list', request=request)
|
|
data['credential_input_sources'] = reverse('api:credential_input_source_list', request=request)
|
|
data['applications'] = reverse('api:o_auth2_application_list', request=request)
|
|
data['tokens'] = reverse('api:o_auth2_token_list', request=request)
|
|
data['metrics'] = reverse('api:metrics_view', request=request)
|
|
data['inventory'] = reverse('api:inventory_list', request=request)
|
|
data['inventory_scripts'] = reverse('api:inventory_script_list', request=request)
|
|
data['inventory_sources'] = reverse('api:inventory_source_list', request=request)
|
|
data['inventory_updates'] = reverse('api:inventory_update_list', request=request)
|
|
data['groups'] = reverse('api:group_list', request=request)
|
|
data['hosts'] = reverse('api:host_list', request=request)
|
|
data['job_templates'] = reverse('api:job_template_list', request=request)
|
|
data['jobs'] = reverse('api:job_list', request=request)
|
|
data['job_events'] = reverse('api:job_event_list', request=request)
|
|
data['ad_hoc_commands'] = reverse('api:ad_hoc_command_list', request=request)
|
|
data['system_job_templates'] = reverse('api:system_job_template_list', request=request)
|
|
data['system_jobs'] = reverse('api:system_job_list', request=request)
|
|
data['schedules'] = reverse('api:schedule_list', request=request)
|
|
data['roles'] = reverse('api:role_list', request=request)
|
|
data['notification_templates'] = reverse('api:notification_template_list', request=request)
|
|
data['notifications'] = reverse('api:notification_list', request=request)
|
|
data['labels'] = reverse('api:label_list', request=request)
|
|
data['unified_job_templates'] = reverse('api:unified_job_template_list', request=request)
|
|
data['unified_jobs'] = reverse('api:unified_job_list', request=request)
|
|
data['activity_stream'] = reverse('api:activity_stream_list', request=request)
|
|
data['workflow_job_templates'] = reverse('api:workflow_job_template_list', request=request)
|
|
data['workflow_jobs'] = reverse('api:workflow_job_list', request=request)
|
|
data['workflow_approvals'] = reverse('api:workflow_approval_list', request=request)
|
|
data['workflow_job_template_nodes'] = reverse('api:workflow_job_template_node_list', request=request)
|
|
data['workflow_job_nodes'] = reverse('api:workflow_job_node_list', request=request)
|
|
return Response(data)
|
|
|
|
|
|
class ApiV2RootView(ApiVersionRootView):
|
|
name = _('Version 2')
|
|
|
|
|
|
class ApiV2PingView(APIView):
|
|
"""A simple view that reports very basic information about this
|
|
instance, which is acceptable to be public information.
|
|
"""
|
|
permission_classes = (AllowAny,)
|
|
authentication_classes = ()
|
|
name = _('Ping')
|
|
swagger_topic = 'System Configuration'
|
|
|
|
def get(self, request, format=None):
|
|
"""Return some basic information about this instance
|
|
|
|
Everything returned here should be considered public / insecure, as
|
|
this requires no auth and is intended for use by the installer process.
|
|
"""
|
|
response = {
|
|
'ha': is_ha_environment(),
|
|
'version': get_awx_version(),
|
|
'active_node': settings.CLUSTER_HOST_ID,
|
|
'install_uuid': settings.INSTALL_UUID,
|
|
}
|
|
|
|
response['instances'] = []
|
|
for instance in Instance.objects.all():
|
|
response['instances'].append(dict(node=instance.hostname, uuid=instance.uuid, heartbeat=instance.modified,
|
|
capacity=instance.capacity, version=instance.version))
|
|
sorted(response['instances'], key=operator.itemgetter('node'))
|
|
response['instance_groups'] = []
|
|
for instance_group in InstanceGroup.objects.prefetch_related('instances'):
|
|
response['instance_groups'].append(dict(name=instance_group.name,
|
|
capacity=instance_group.capacity,
|
|
instances=[x.hostname for x in instance_group.instances.all()]))
|
|
return Response(response)
|
|
|
|
|
|
class ApiV2SubscriptionView(APIView):
|
|
|
|
permission_classes = (IsAuthenticated,)
|
|
name = _('Subscriptions')
|
|
swagger_topic = 'System Configuration'
|
|
|
|
def check_permissions(self, request):
|
|
super(ApiV2SubscriptionView, self).check_permissions(request)
|
|
if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}:
|
|
self.permission_denied(request) # Raises PermissionDenied exception.
|
|
|
|
def post(self, request):
|
|
from awx.main.utils.common import get_licenser
|
|
data = request.data.copy()
|
|
if data.get('subscriptions_password') == '$encrypted$':
|
|
data['subscriptions_password'] = settings.SUBSCRIPTIONS_PASSWORD
|
|
try:
|
|
user, pw = data.get('subscriptions_username'), data.get('subscriptions_password')
|
|
with set_environ(**settings.AWX_TASK_ENV):
|
|
validated = get_licenser().validate_rh(user, pw)
|
|
if user:
|
|
settings.SUBSCRIPTIONS_USERNAME = data['subscriptions_username']
|
|
if pw:
|
|
settings.SUBSCRIPTIONS_PASSWORD = data['subscriptions_password']
|
|
except Exception as exc:
|
|
msg = _("Invalid Subscription")
|
|
if (
|
|
isinstance(exc, requests.exceptions.HTTPError) and
|
|
getattr(getattr(exc, 'response', None), 'status_code', None) == 401
|
|
):
|
|
msg = _("The provided credentials are invalid (HTTP 401).")
|
|
elif isinstance(exc, requests.exceptions.ProxyError):
|
|
msg = _("Unable to connect to proxy server.")
|
|
elif isinstance(exc, requests.exceptions.ConnectionError):
|
|
msg = _("Could not connect to subscription service.")
|
|
elif isinstance(exc, (ValueError, OSError)) and exc.args:
|
|
msg = exc.args[0]
|
|
else:
|
|
logger.exception(smart_text(u"Invalid subscription submitted."),
|
|
extra=dict(actor=request.user.username))
|
|
return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
return Response(validated)
|
|
|
|
|
|
class ApiV2AttachView(APIView):
|
|
|
|
permission_classes = (IsAuthenticated,)
|
|
name = _('Attach Subscription')
|
|
swagger_topic = 'System Configuration'
|
|
|
|
def check_permissions(self, request):
|
|
super(ApiV2AttachView, self).check_permissions(request)
|
|
if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}:
|
|
self.permission_denied(request) # Raises PermissionDenied exception.
|
|
|
|
def post(self, request):
|
|
data = request.data.copy()
|
|
pool_id = data.get('pool_id', None)
|
|
if not pool_id:
|
|
return Response({"error": _("No subscription pool ID provided.")}, status=status.HTTP_400_BAD_REQUEST)
|
|
user = getattr(settings, 'SUBSCRIPTIONS_USERNAME', None)
|
|
pw = getattr(settings, 'SUBSCRIPTIONS_PASSWORD', None)
|
|
if pool_id and user and pw:
|
|
from awx.main.utils.common import get_licenser
|
|
data = request.data.copy()
|
|
try:
|
|
with set_environ(**settings.AWX_TASK_ENV):
|
|
validated = get_licenser().validate_rh(user, pw)
|
|
except Exception as exc:
|
|
msg = _("Invalid Subscription")
|
|
if (
|
|
isinstance(exc, requests.exceptions.HTTPError) and
|
|
getattr(getattr(exc, 'response', None), 'status_code', None) == 401
|
|
):
|
|
msg = _("The provided credentials are invalid (HTTP 401).")
|
|
elif isinstance(exc, requests.exceptions.ProxyError):
|
|
msg = _("Unable to connect to proxy server.")
|
|
elif isinstance(exc, requests.exceptions.ConnectionError):
|
|
msg = _("Could not connect to subscription service.")
|
|
elif isinstance(exc, (ValueError, OSError)) and exc.args:
|
|
msg = exc.args[0]
|
|
else:
|
|
logger.exception(smart_text(u"Invalid subscription submitted."),
|
|
extra=dict(actor=request.user.username))
|
|
return Response({"error": msg}, status=status.HTTP_400_BAD_REQUEST)
|
|
for sub in validated:
|
|
if sub['pool_id'] == pool_id:
|
|
sub['valid_key'] = True
|
|
settings.LICENSE = sub
|
|
return Response(sub)
|
|
|
|
return Response({"error": _("Error processing subscription metadata.")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
class ApiV2ConfigView(APIView):
|
|
|
|
permission_classes = (IsAuthenticated,)
|
|
name = _('Configuration')
|
|
swagger_topic = 'System Configuration'
|
|
|
|
def check_permissions(self, request):
|
|
super(ApiV2ConfigView, self).check_permissions(request)
|
|
if not request.user.is_superuser and request.method.lower() not in {'options', 'head', 'get'}:
|
|
self.permission_denied(request) # Raises PermissionDenied exception.
|
|
|
|
def get(self, request, format=None):
|
|
'''Return various sitewide configuration settings'''
|
|
|
|
from awx.main.utils.common import get_licenser
|
|
license_data = get_licenser().validate()
|
|
|
|
if not license_data.get('valid_key', False):
|
|
license_data = {}
|
|
|
|
pendo_state = settings.PENDO_TRACKING_STATE if settings.PENDO_TRACKING_STATE in ('off', 'anonymous', 'detailed') else 'off'
|
|
|
|
data = dict(
|
|
time_zone=settings.TIME_ZONE,
|
|
license_info=license_data,
|
|
version=get_awx_version(),
|
|
ansible_version=get_ansible_version(),
|
|
eula=render_to_string("eula.md") if license_data.get('license_type', 'UNLICENSED') != 'open' else '',
|
|
analytics_status=pendo_state,
|
|
analytics_collectors=all_collectors(),
|
|
become_methods=PRIVILEGE_ESCALATION_METHODS,
|
|
)
|
|
|
|
# If LDAP is enabled, user_ldap_fields will return a list of field
|
|
# names that are managed by LDAP and should be read-only for users with
|
|
# a non-empty ldap_dn attribute.
|
|
if getattr(settings, 'AUTH_LDAP_SERVER_URI', None):
|
|
user_ldap_fields = ['username', 'password']
|
|
user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_ATTR_MAP', {}).keys())
|
|
user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys())
|
|
data['user_ldap_fields'] = user_ldap_fields
|
|
|
|
if request.user.is_superuser \
|
|
or request.user.is_system_auditor \
|
|
or Organization.accessible_objects(request.user, 'admin_role').exists() \
|
|
or Organization.accessible_objects(request.user, 'auditor_role').exists() \
|
|
or Organization.accessible_objects(request.user, 'project_admin_role').exists():
|
|
data.update(dict(
|
|
project_base_dir = settings.PROJECTS_ROOT,
|
|
project_local_paths = Project.get_local_path_choices(),
|
|
custom_virtualenvs = get_custom_venv_choices()
|
|
))
|
|
elif JobTemplate.accessible_objects(request.user, 'admin_role').exists():
|
|
data['custom_virtualenvs'] = get_custom_venv_choices()
|
|
|
|
return Response(data)
|
|
|
|
|
|
def post(self, request):
|
|
if not isinstance(request.data, dict):
|
|
return Response({"error": _("Invalid subscription data")}, status=status.HTTP_400_BAD_REQUEST)
|
|
if "eula_accepted" not in request.data:
|
|
return Response({"error": _("Missing 'eula_accepted' property")}, status=status.HTTP_400_BAD_REQUEST)
|
|
try:
|
|
eula_accepted = to_python_boolean(request.data["eula_accepted"])
|
|
except ValueError:
|
|
return Response({"error": _("'eula_accepted' value is invalid")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not eula_accepted:
|
|
return Response({"error": _("'eula_accepted' must be True")}, status=status.HTTP_400_BAD_REQUEST)
|
|
request.data.pop("eula_accepted")
|
|
try:
|
|
data_actual = json.dumps(request.data)
|
|
except Exception:
|
|
logger.info(smart_text(u"Invalid JSON submitted for license."),
|
|
extra=dict(actor=request.user.username))
|
|
return Response({"error": _("Invalid JSON")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
from awx.main.utils.common import get_licenser
|
|
license_data = json.loads(data_actual)
|
|
if 'license_key' in license_data:
|
|
return Response({"error": _('Legacy license submitted. A subscription manifest is now required.')}, status=status.HTTP_400_BAD_REQUEST)
|
|
if 'manifest' in license_data:
|
|
try:
|
|
json_actual = json.loads(base64.b64decode(license_data['manifest']))
|
|
if 'license_key' in json_actual:
|
|
return Response(
|
|
{"error": _('Legacy license submitted. A subscription manifest is now required.')},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
license_data = validate_entitlement_manifest(license_data['manifest'])
|
|
except ValueError as e:
|
|
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
|
except Exception:
|
|
logger.exception('Invalid manifest submitted. {}')
|
|
return Response({"error": _('Invalid manifest submitted.')}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
try:
|
|
license_data_validated = get_licenser().license_from_manifest(license_data)
|
|
except Exception:
|
|
logger.warning(smart_text(u"Invalid subscription submitted."),
|
|
extra=dict(actor=request.user.username))
|
|
return Response({"error": _("Invalid License")}, status=status.HTTP_400_BAD_REQUEST)
|
|
else:
|
|
license_data_validated = get_licenser().validate()
|
|
|
|
# If the license is valid, write it to the database.
|
|
if license_data_validated['valid_key']:
|
|
if not settings_registry.is_setting_read_only('TOWER_URL_BASE'):
|
|
settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host())
|
|
return Response(license_data_validated)
|
|
|
|
logger.warning(smart_text(u"Invalid subscription submitted."),
|
|
extra=dict(actor=request.user.username))
|
|
return Response({"error": _("Invalid subscription")}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
def delete(self, request):
|
|
try:
|
|
settings.LICENSE = {}
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
except Exception:
|
|
# FIX: Log
|
|
return Response({"error": _("Failed to remove license.")}, status=status.HTTP_400_BAD_REQUEST)
|