diff --git a/MANIFEST.in b/MANIFEST.in index 703daa31b3..36d627dc10 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,6 +15,7 @@ recursive-exclude awx/main/tests * recursive-exclude awx/ui/client * recursive-exclude awx/settings local_settings.py* include tools/scripts/request_tower_configuration.sh +include tools/scripts/request_tower_configuration.ps1 include tools/scripts/ansible-tower-service include tools/munin_monitors/* include tools/sosreport/* diff --git a/Makefile b/Makefile index d7e7f091c2..8665341cb1 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ PYTHON = python SITELIB=$(shell $(PYTHON) -c "from distutils.sysconfig import get_python_lib; print get_python_lib()") OFFICIAL ?= no PACKER ?= packer +PACKER_BUILD_OPTS ?= -var 'official=$(OFFICIAL)' -var 'aw_repo_url=$(AW_REPO_URL)' GRUNT ?= $(shell [ -t 0 ] && echo "grunt" || echo "grunt --no-color") TESTEM ?= ./node_modules/.bin/testem TESTEM_DEBUG_BROWSER ?= Chrome @@ -10,7 +11,7 @@ MOCHA_BIN ?= ./node_modules/.bin/mocha NODE ?= node NPM_BIN ?= npm DEPS_SCRIPT ?= packaging/bundle/deps.py -AW_REPO_URL ?= "http://releases.ansible.com/ansible-tower" +GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) CLIENT_TEST_DIR ?= build_test @@ -33,8 +34,10 @@ GIT_REMOTE_URL = $(shell git config --get remote.origin.url) BUILD = 0.git$(DATE) ifeq ($(OFFICIAL),yes) RELEASE ?= 1 + AW_REPO_URL ?= http://releases.ansible.com/ansible-tower else RELEASE ?= $(BUILD) + AW_REPO_URL ?= http://jenkins.testing.ansible.com/ansible-tower_nightlies_RTYUIOPOIUYTYU/$(GIT_BRANCH) endif # Allow AMI license customization @@ -57,11 +60,9 @@ endif ifeq ($(OFFICIAL),yes) SETUP_TAR_NAME=$(NAME)-setup-$(VERSION) SDIST_TAR_NAME=$(NAME)-$(VERSION) - PACKER_BUILD_OPTS ?= -var-file=vars-release.json else SETUP_TAR_NAME=$(NAME)-setup-$(VERSION)-$(RELEASE) SDIST_TAR_NAME=$(NAME)-$(VERSION)-$(RELEASE) - PACKER_BUILD_OPTS ?= -var-file=vars-nightly.json endif SDIST_TAR_FILE=$(SDIST_TAR_NAME).tar.gz SETUP_TAR_FILE=$(SETUP_TAR_NAME).tar.gz @@ -650,7 +651,7 @@ reprepro: deb-build/$(DEB_NVRA).deb reprepro/conf $(REPREPRO_BIN) $(REPREPRO_OPTS) clearvanished for COMPONENT in non-free $(VERSION); do \ $(REPREPRO_BIN) $(REPREPRO_OPTS) -C $$COMPONENT remove $(DEB_DIST) $(NAME) ; \ - $(REPREPRO_BIN) $(REPREPRO_OPTS) -C $$COMPONENT --keepunreferencedfiles --ignore=brokenold includedeb $(DEB_DIST) deb-build/$(DEB_NVRA).deb ; \ + $(REPREPRO_BIN) $(REPREPRO_OPTS) -C $$COMPONENT --ignore=brokenold includedeb $(DEB_DIST) deb-build/$(DEB_NVRA).deb ; \ done diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c53da8ada4..1ab055d8af 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -630,6 +630,12 @@ class UserSerializer(BaseSerializer): new_password = None except AttributeError: pass + if (getattr(settings, 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', None) or + getattr(settings, 'SOCIAL_AUTH_GITHUB_KEY', None) or + getattr(settings, 'SOCIAL_AUTH_GITHUB_ORG_KEY', None) or + getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or + getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all(): + new_password = None if new_password: obj.set_password(new_password) if not obj.password: @@ -2004,11 +2010,12 @@ class ScheduleSerializer(BaseSerializer): class ActivityStreamSerializer(BaseSerializer): changes = serializers.SerializerMethodField('get_changes') + object_association = serializers.SerializerMethodField('get_object_association') class Meta: model = ActivityStream fields = ('*', '-name', '-description', '-created', '-modified', - 'timestamp', 'operation', 'changes', 'object1', 'object2') + 'timestamp', 'operation', 'changes', 'object1', 'object2', 'object_association') def get_fields(self): ret = super(ActivityStreamSerializer, self).get_fields() @@ -2033,6 +2040,13 @@ class ActivityStreamSerializer(BaseSerializer): logger.warn("Error deserializing activity stream json changes") return {} + def get_object_association(self, obj): + try: + return obj.object_relationship_type.split(".")[-1].split("_")[1] + except: + pass + return "" + def get_related(self, obj): rel = {} if obj.actor is not None: diff --git a/awx/api/views.py b/awx/api/views.py index c101b43bed..c6f89bd116 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -11,6 +11,7 @@ import time import socket import sys import errno +from base64 import b64encode # Django from django.conf import settings @@ -526,7 +527,10 @@ class AuthView(APIView): def get(self, request): data = SortedDict() err_backend, err_message = request.session.get('social_auth_error', (None, None)) - for name, backend in load_backends(settings.AUTHENTICATION_BACKENDS).items(): + auth_backends = load_backends(settings.AUTHENTICATION_BACKENDS).items() + # Return auth backends in consistent order: Google, GitHub, SAML. + auth_backends.sort(key=lambda x: 'g' if x[0] == 'google-oauth2' else x[0]) + for name, backend in auth_backends: if (not feature_exists('enterprise_auth') and not feature_enabled('ldap')) or \ (not feature_enabled('enterprise_auth') and @@ -540,7 +544,7 @@ class AuthView(APIView): } if name == 'saml': backend_data['metadata_url'] = reverse('sso:saml_metadata') - for idp in settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys(): + for idp in sorted(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys()): saml_backend_data = dict(backend_data.items()) saml_backend_data['login_url'] = '%s?idp=%s' % (login_url, idp) full_backend_name = '%s:%s' % (name, idp) @@ -2861,8 +2865,10 @@ class UnifiedJobStdout(RetrieveAPIView): return Response({'range': {'start': 0, 'end': 1, 'absolute_end': 1}, 'content': response_message}) else: return Response(response_message) - + if request.accepted_renderer.format in ('html', 'api', 'json'): + content_format = request.QUERY_PARAMS.get('content_format', 'html') + content_encoding = request.QUERY_PARAMS.get('content_encoding', None) start_line = request.QUERY_PARAMS.get('start_line', 0) end_line = request.QUERY_PARAMS.get('end_line', None) dark_val = request.QUERY_PARAMS.get('dark', '') @@ -2883,7 +2889,10 @@ class UnifiedJobStdout(RetrieveAPIView): if request.accepted_renderer.format == 'api': return Response(mark_safe(data)) if request.accepted_renderer.format == 'json': - return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end}, 'content': body}) + if content_encoding == 'base64' and content_format == 'ansi': + return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end}, 'content': b64encode(content)}) + elif content_format == 'html': + return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end}, 'content': body}) return Response(data) elif request.accepted_renderer.format == 'ansi': return Response(unified_job.result_stdout_raw) diff --git a/awx/main/access.py b/awx/main/access.py index b4a798fe21..4af42b28f2 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -146,7 +146,7 @@ class BaseAccess(object): def can_unattach(self, obj, sub_obj, relationship): return self.can_change(obj, None) - def check_license(self, add_host=False, feature=None): + def check_license(self, add_host=False, feature=None, check_expiration=True): reader = TaskSerializer() validation_info = reader.from_file() if ('test' in sys.argv or 'jenkins' in sys.argv) and not os.environ.get('SKIP_LICENSE_FIXUP_FOR_TEST', ''): @@ -154,9 +154,9 @@ class BaseAccess(object): validation_info['time_remaining'] = 99999999 validation_info['grace_period_remaining'] = 99999999 - if validation_info.get('time_remaining', None) is None: + if check_expiration and validation_info.get('time_remaining', None) is None: raise PermissionDenied("license is missing") - if validation_info.get("grace_period_remaining") <= 0: + if check_expiration and validation_info.get("grace_period_remaining") <= 0: raise PermissionDenied("license has expired") free_instances = validation_info.get('free_instances', 0) @@ -262,7 +262,7 @@ class OrganizationAccess(BaseAccess): self.user in obj.admins.all()) def can_delete(self, obj): - self.check_license(feature='multiple_organizations') + self.check_license(feature='multiple_organizations', check_expiration=False) return self.can_change(obj, None) class InventoryAccess(BaseAccess): diff --git a/awx/main/backend.py b/awx/main/backend.py deleted file mode 100644 index ad789af9fc..0000000000 --- a/awx/main/backend.py +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Django -from django.dispatch import receiver - -# django-auth-ldap -from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings -from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend -from django_auth_ldap.backend import populate_user - -# Ansible Tower -from awx.api.license import feature_enabled - -class LDAPSettings(BaseLDAPSettings): - - defaults = dict(BaseLDAPSettings.defaults.items() + { - 'ORGANIZATION_MAP': {}, - 'TEAM_MAP': {}, - }.items()) - -class LDAPBackend(BaseLDAPBackend): - ''' - Custom LDAP backend for AWX. - ''' - - settings_prefix = 'AUTH_LDAP_' - - def _get_settings(self): - if self._settings is None: - self._settings = LDAPSettings(self.settings_prefix) - return self._settings - - def _set_settings(self, settings): - self._settings = settings - - settings = property(_get_settings, _set_settings) - - def authenticate(self, username, password): - if not self.settings.SERVER_URI or not feature_enabled('ldap'): - return None - return super(LDAPBackend, self).authenticate(username, password) - - def get_user(self, user_id): - if not self.settings.SERVER_URI or not feature_enabled('ldap'): - return None - return super(LDAPBackend, self).get_user(user_id) - - # Disable any LDAP based authorization / permissions checking. - - def has_perm(self, user, perm, obj=None): - return False - - def has_module_perms(self, user, app_label): - return False - - def get_all_permissions(self, user, obj=None): - return set() - - def get_group_permissions(self, user, obj=None): - return set() - -def _update_m2m_from_groups(user, ldap_user, rel, opts, remove=False): - ''' - Hepler function to update m2m relationship based on LDAP group membership. - ''' - should_add = False - if opts is None: - return - elif not opts: - pass - elif opts is True: - should_add = True - else: - if isinstance(opts, basestring): - opts = [opts] - for group_dn in opts: - if not isinstance(group_dn, basestring): - continue - if ldap_user._get_groups().is_member_of(group_dn): - should_add = True - if should_add: - rel.add(user) - elif remove: - rel.remove(user) - -@receiver(populate_user) -def on_populate_user(sender, **kwargs): - ''' - Handle signal from LDAP backend to populate the user object. Update user - organization/team memberships according to their LDAP groups. - ''' - from awx.main.models import Organization, Team - user = kwargs['user'] - ldap_user = kwargs['ldap_user'] - backend = ldap_user.backend - - # Update organization membership based on group memberships. - org_map = getattr(backend.settings, 'ORGANIZATION_MAP', {}) - for org_name, org_opts in org_map.items(): - org, created = Organization.objects.get_or_create(name=org_name) - remove = bool(org_opts.get('remove', False)) - admins_opts = org_opts.get('admins', None) - remove_admins = bool(org_opts.get('remove_admins', remove)) - _update_m2m_from_groups(user, ldap_user, org.admins, admins_opts, - remove_admins) - users_opts = org_opts.get('users', None) - remove_users = bool(org_opts.get('remove_users', remove)) - _update_m2m_from_groups(user, ldap_user, org.users, users_opts, - remove_users) - - # Update team membership based on group memberships. - team_map = getattr(backend.settings, 'TEAM_MAP', {}) - for team_name, team_opts in team_map.items(): - if 'organization' not in team_opts: - continue - org, created = Organization.objects.get_or_create(name=team_opts['organization']) - team, created = Team.objects.get_or_create(name=team_name, organization=org) - users_opts = team_opts.get('users', None) - remove = bool(team_opts.get('remove', False)) - _update_m2m_from_groups(user, ldap_user, team.users, users_opts, - remove) - - # Update user profile to store LDAP DN. - profile = user.profile - if profile.ldap_dn != ldap_user.dn: - profile.ldap_dn = ldap_user.dn - profile.save() diff --git a/awx/main/management/commands/_base_instance.py b/awx/main/management/commands/_base_instance.py index be2b0abd3c..c92fa3b640 100644 --- a/awx/main/management/commands/_base_instance.py +++ b/awx/main/management/commands/_base_instance.py @@ -21,6 +21,7 @@ class BaseCommandInstance(BaseCommand): def __init__(self): super(BaseCommandInstance, self).__init__() + self.enforce_primary_role = False self.enforce_roles = False self.enforce_hostname_set = False self.enforce_unique_find = False diff --git a/awx/main/management/commands/run_socketio_service.py b/awx/main/management/commands/run_socketio_service.py index c4439218db..56d8cc5b1c 100644 --- a/awx/main/management/commands/run_socketio_service.py +++ b/awx/main/management/commands/run_socketio_service.py @@ -46,43 +46,52 @@ class SocketSession(object): return bool(not auth_token.is_expired()) class SocketSessionManager(object): - socket_sessions = [] - socket_session_token_key_map = {} - @classmethod - def _prune(cls): - if len(cls.socket_sessions) > 1000: - session = cls.socket_session[0] - del cls.socket_session_token_key_map[session.token_key] - cls.sessions = cls.socket_sessions[1:] + def __init__(self): + self.SESSIONS_MAX = 1000 + self.socket_sessions = [] + self.socket_session_token_key_map = {} + + def _prune(self): + if len(self.socket_sessions) > self.SESSIONS_MAX: + session = self.socket_sessions[0] + entries = self.socket_session_token_key_map[session.token_key] + del entries[session.session_id] + if len(entries) == 0: + del self.socket_session_token_key_map[session.token_key] + self.socket_sessions.pop(0) ''' Returns an dict of sessions ''' - @classmethod - def lookup(cls, token_key=None): + def lookup(self, token_key=None): if not token_key: raise ValueError("token_key required") - return cls.socket_session_token_key_map.get(token_key, None) + return self.socket_session_token_key_map.get(token_key, None) - @classmethod - def add_session(cls, session): - cls.socket_sessions.append(session) - entries = cls.socket_session_token_key_map.get(session.token_key, None) + def add_session(self, session): + self.socket_sessions.append(session) + entries = self.socket_session_token_key_map.get(session.token_key, None) if not entries: entries = {} - cls.socket_session_token_key_map[session.token_key] = entries + self.socket_session_token_key_map[session.token_key] = entries entries[session.session_id] = session - cls._prune() + self._prune() + return session class SocketController(object): - server = None - @classmethod - def broadcast_packet(cls, packet): + def __init__(self, SocketSessionManager): + self.server = None + self.SocketSessionManager = SocketSessionManager + + def add_session(self, session): + return self.SocketSessionManager.add_session(session) + + def broadcast_packet(self, packet): # Broadcast message to everyone at endpoint # Loop over the 'raw' list of sockets (don't trust our list) - for session_id, socket in list(cls.server.sockets.iteritems()): + for session_id, socket in list(self.server.sockets.iteritems()): socket_session = socket.session.get('socket_session', None) if socket_session and socket_session.is_valid(): try: @@ -91,11 +100,10 @@ class SocketController(object): logger.error("Error sending client packet to %s: %s" % (str(session_id), str(packet))) logger.error("Error was: " + str(e)) - @classmethod - def send_packet(cls, packet, token_key): + def send_packet(self, packet, token_key): if not token_key: raise ValueError("token_key is required") - socket_sessions = SocketSessionManager.lookup(token_key=token_key) + socket_sessions = self.SocketSessionManager.lookup(token_key=token_key) # We may not find the socket_session if the user disconnected # (it's actually more compliciated than that because of our prune logic) if not socket_sessions: @@ -112,11 +120,12 @@ class SocketController(object): logger.error("Error sending client packet to %s: %s" % (str(socket_session.session_id), str(packet))) logger.error("Error was: " + str(e)) - @classmethod - def set_server(cls, server): - cls.server = server + def set_server(self, server): + self.server = server return server +socketController = SocketController(SocketSessionManager()) + # # Socket session is attached to self.session['socket_session'] # self.session and self.socket.session point to the same dict @@ -140,7 +149,7 @@ class TowerBaseNamespace(BaseNamespace): socket_session = SocketSession(self.socket.sessid, request_token, self.socket) if socket_session.is_db_token_valid(): self.session['socket_session'] = socket_session - SocketSessionManager.add_session(socket_session) + socketController.add_session(socket_session) else: socket_session.invalidate() @@ -240,9 +249,9 @@ def notification_handler(server): if 'token_key' in message: # Best practice not to send the token over the socket - SocketController.send_packet(packet, message.pop('token_key')) + socketController.send_packet(packet, message.pop('token_key')) else: - SocketController.broadcast_packet(packet) + socketController.broadcast_packet(packet) class Command(NoArgsCommand): ''' @@ -270,7 +279,7 @@ class Command(NoArgsCommand): logger.info('Listening on port http://0.0.0.0:' + str(socketio_listen_port)) server = SocketIOServer(('0.0.0.0', socketio_listen_port), TowerSocket(), resource='socket.io') - SocketController.set_server(server) + socketController.set_server(server) handler_thread = Thread(target=notification_handler, args=(server,)) handler_thread.daemon = True handler_thread.start() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 8b83383737..93303f511c 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -170,10 +170,10 @@ def handle_work_error(self, task_id, subtasks=None): instance_name = '' if each_task['type'] == 'project_update': instance = ProjectUpdate.objects.get(id=each_task['id']) - instance_name = instance.project.name + instance_name = instance.name elif each_task['type'] == 'inventory_update': instance = InventoryUpdate.objects.get(id=each_task['id']) - instance_name = instance.inventory_source.inventory.name + instance_name = instance.name elif each_task['type'] == 'job': instance = Job.objects.get(id=each_task['id']) instance_name = instance.job_template.name @@ -191,7 +191,7 @@ def handle_work_error(self, task_id, subtasks=None): if instance.celery_task_id != task_id: instance.status = 'failed' instance.failed = True - instance.job_explanation = 'Previous Task Failed: {"task_type": "%s", "task_name": "%s", "task_id": "%s"}' % \ + instance.job_explanation = 'Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % \ (first_task_type, first_task_name, first_task_id) instance.save() instance.socketio_emit_status("failed") @@ -614,6 +614,21 @@ class RunJob(BaseTask): if credential.ssh_key_data not in (None, ''): private_data[cred_name] = decrypt_field(credential, 'ssh_key_data') or '' + if job.cloud_credential and job.cloud_credential.kind == 'openstack': + credential = job.cloud_credential + openstack_auth = dict(auth_url=credential.host, + username=credential.username, + password=decrypt_field(credential, "password"), + project_name=credential.project) + openstack_data = { + 'clouds': { + 'devstack': { + 'auth': openstack_auth, + }, + }, + } + private_data['cloud_credential'] = yaml.safe_dump(openstack_data, default_flow_style=False, allow_unicode=True) + return private_data def build_passwords(self, job, **kwargs): @@ -689,6 +704,8 @@ class RunJob(BaseTask): env['VMWARE_USER'] = cloud_cred.username env['VMWARE_PASSWORD'] = decrypt_field(cloud_cred, 'password') env['VMWARE_HOST'] = cloud_cred.host + elif cloud_cred and cloud_cred.kind == 'openstack': + env['OS_CLIENT_CONFIG_FILE'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') # Set environment variables related to scan jobs if job.job_type == PERM_INVENTORY_SCAN: diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index a7a6fe116f..bdea0523a8 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -28,11 +28,11 @@ from django.test.utils import override_settings # AWX from awx.main.models import * # noqa -from awx.main.backend import LDAPSettings from awx.main.management.commands.run_callback_receiver import CallbackReceiver from awx.main.management.commands.run_task_system import run_taskmanager from awx.main.utils import get_ansible_version from awx.main.task_engine import TaskEngager as LicenseWriter +from awx.sso.backends import LDAPSettings TEST_PLAYBOOK = '''- hosts: mygroup gather_facts: false diff --git a/awx/main/tests/commands/__init__.py b/awx/main/tests/commands/__init__.py index 9c099516c1..dc89a6f8b6 100644 --- a/awx/main/tests/commands/__init__.py +++ b/awx/main/tests/commands/__init__.py @@ -7,3 +7,6 @@ from .run_fact_cache_receiver import * # noqa from .commands_monolithic import * # noqa from .cleanup_facts import * # noqa from .age_deleted import * # noqa +from .remove_instance import * # noqa +from .run_socketio_service import * # noqa + diff --git a/awx/main/tests/commands/remove_instance.py b/awx/main/tests/commands/remove_instance.py new file mode 100644 index 0000000000..d93285ea7e --- /dev/null +++ b/awx/main/tests/commands/remove_instance.py @@ -0,0 +1,39 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved + +# Python +import uuid + +# AWX +from awx.main.tests.base import BaseTest +from awx.main.tests.commands.base import BaseCommandMixin +from awx.main.models import * # noqa + +__all__ = ['RemoveInstanceCommandFunctionalTest'] + +class RemoveInstanceCommandFunctionalTest(BaseCommandMixin, BaseTest): + uuids = [] + instances = [] + + def setup_instances(self): + self.uuids = [uuid.uuid4().hex for x in range(0, 3)] + self.instances.append(Instance(uuid=settings.SYSTEM_UUID, primary=True, hostname='127.0.0.1')) + self.instances.append(Instance(uuid=self.uuids[0], primary=False, hostname='127.0.0.2')) + self.instances.append(Instance(uuid=self.uuids[1], primary=False, hostname='127.0.0.3')) + self.instances.append(Instance(uuid=self.uuids[2], primary=False, hostname='127.0.0.4')) + for x in self.instances: + x.save() + + def setUp(self): + super(RemoveInstanceCommandFunctionalTest, self).setUp() + self.create_test_license_file() + self.setup_instances() + self.setup_users() + + def test_default(self): + self.assertEqual(Instance.objects.filter(hostname="127.0.0.2").count(), 1) + result, stdout, stderr = self.run_command('remove_instance', hostname='127.0.0.2') + self.assertIsNone(result) + self.assertEqual(stdout, 'Successfully removed instance (uuid="%s",hostname="127.0.0.2",role="secondary").\n' % (self.uuids[0])) + self.assertEqual(Instance.objects.filter(hostname="127.0.0.2").count(), 0) + diff --git a/awx/main/tests/commands/run_socketio_service.py b/awx/main/tests/commands/run_socketio_service.py new file mode 100644 index 0000000000..be882d6b20 --- /dev/null +++ b/awx/main/tests/commands/run_socketio_service.py @@ -0,0 +1,116 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved + +# Python +from mock import MagicMock, Mock + +# Django +from django.test import SimpleTestCase + +# AWX +from awx.fact.models.fact import * # noqa +from awx.main.management.commands.run_socketio_service import SocketSessionManager, SocketSession, SocketController + +__all__ = ['SocketSessionManagerUnitTest', 'SocketControllerUnitTest',] + +class WeakRefable(): + pass + +class SocketSessionManagerUnitTest(SimpleTestCase): + + def setUp(self): + self.session_manager = SocketSessionManager() + super(SocketSessionManagerUnitTest, self).setUp() + + def create_sessions(self, count, token_key=None): + self.sessions = [] + self.count = count + for i in range(0, count): + self.sessions.append(SocketSession(i, token_key or i, WeakRefable())) + self.session_manager.add_session(self.sessions[i]) + + def test_multiple_session_diff_token(self): + self.create_sessions(10) + + for s in self.sessions: + self.assertIn(s.token_key, self.session_manager.socket_session_token_key_map) + self.assertEqual(s, self.session_manager.socket_session_token_key_map[s.token_key][s.session_id]) + + + def test_multiple_session_same_token(self): + self.create_sessions(10, token_key='foo') + + sessions_dict = self.session_manager.lookup("foo") + self.assertEqual(len(sessions_dict), 10) + for s in self.sessions: + self.assertIn(s.session_id, sessions_dict) + self.assertEqual(s, sessions_dict[s.session_id]) + + def test_prune_sessions_max(self): + self.create_sessions(self.session_manager.SESSIONS_MAX + 10) + + self.assertEqual(len(self.session_manager.socket_sessions), self.session_manager.SESSIONS_MAX) + + +class SocketControllerUnitTest(SimpleTestCase): + + def setUp(self): + self.socket_controller = SocketController(SocketSessionManager()) + server = Mock() + self.socket_controller.set_server(server) + super(SocketControllerUnitTest, self).setUp() + + def create_clients(self, count, token_key=None): + self.sessions = [] + self.sockets =[] + self.count = count + self.sockets_dict = {} + for i in range(0, count): + if isinstance(token_key, list): + token_key_actual = token_key[i] + else: + token_key_actual = token_key or i + socket = MagicMock(session=dict()) + socket_session = SocketSession(i, token_key_actual, socket) + self.sockets.append(socket) + self.sessions.append(socket_session) + self.sockets_dict[i] = socket + self.socket_controller.add_session(socket_session) + + socket.session['socket_session'] = socket_session + socket.send_packet = Mock() + self.socket_controller.server.sockets = self.sockets_dict + + def test_broadcast_packet(self): + self.create_clients(10) + packet = { + "hello": "world" + } + self.socket_controller.broadcast_packet(packet) + for s in self.sockets: + s.send_packet.assert_called_with(packet) + + def test_send_packet(self): + self.create_clients(5, token_key=[0, 1, 2, 3, 4]) + packet = { + "hello": "world" + } + self.socket_controller.send_packet(packet, 2) + self.assertEqual(0, len(self.sockets[0].send_packet.mock_calls)) + self.assertEqual(0, len(self.sockets[1].send_packet.mock_calls)) + self.sockets[2].send_packet.assert_called_once_with(packet) + self.assertEqual(0, len(self.sockets[3].send_packet.mock_calls)) + self.assertEqual(0, len(self.sockets[4].send_packet.mock_calls)) + + def test_send_packet_multiple_sessions_one_token(self): + self.create_clients(5, token_key=[0, 1, 1, 1, 2]) + packet = { + "hello": "world" + } + self.socket_controller.send_packet(packet, 1) + self.assertEqual(0, len(self.sockets[0].send_packet.mock_calls)) + self.sockets[1].send_packet.assert_called_once_with(packet) + self.sockets[2].send_packet.assert_called_once_with(packet) + self.sockets[3].send_packet.assert_called_once_with(packet) + self.assertEqual(0, len(self.sockets[4].send_packet.mock_calls)) + diff --git a/awx/plugins/inventory/ec2.py b/awx/plugins/inventory/ec2.py index 6fc57f6c95..b8b1d43dea 100755 --- a/awx/plugins/inventory/ec2.py +++ b/awx/plugins/inventory/ec2.py @@ -327,6 +327,12 @@ class Ec2Inventory(object): else: self.nested_groups = False + # Replace dash or not in group names + if config.has_option('ec2', 'replace_dash_in_groups'): + self.replace_dash_in_groups = config.getboolean('ec2', 'replace_dash_in_groups') + else: + self.replace_dash_in_groups = True + # Configure which groups should be created. group_by_options = [ 'group_by_instance_id', @@ -360,7 +366,7 @@ class Ec2Inventory(object): self.pattern_include = re.compile(pattern_include) else: self.pattern_include = None - except configparser.NoOptionError as e: + except configparser.NoOptionError: self.pattern_include = None # Do we need to exclude hosts that match a pattern? @@ -370,7 +376,7 @@ class Ec2Inventory(object): self.pattern_exclude = re.compile(pattern_exclude) else: self.pattern_exclude = None - except configparser.NoOptionError as e: + except configparser.NoOptionError: self.pattern_exclude = None # Instance filters (see boto and EC2 API docs). Ignore invalid filters. @@ -697,7 +703,8 @@ class Ec2Inventory(object): self.push(self.inventory, key, dest) if self.nested_groups: self.push_group(self.inventory, 'tags', self.to_safe("tag_" + k)) - self.push_group(self.inventory, self.to_safe("tag_" + k), key) + if v: + self.push_group(self.inventory, self.to_safe("tag_" + k), key) # Inventory: Group by Route53 domain names if enabled if self.route53_enabled and self.group_by_route53_names: @@ -1285,10 +1292,11 @@ class Ec2Inventory(object): return re.sub('([a-z0-9])([A-Z])', r'\1_\2', temp).lower() def to_safe(self, word): - ''' Converts 'bad' characters in a string to underscores so they can be - used as Ansible groups ''' - - return re.sub("[^A-Za-z0-9\_]", "_", word) + ''' Converts 'bad' characters in a string to underscores so they can be used as Ansible groups ''' + regex = "[^A-Za-z0-9\_" + if self.replace_dash_in_groups: + regex += "\-" + return re.sub(regex + "]", "_", word) def json_format_dict(self, data, pretty=False): ''' Converts a dict to a JSON object and dumps it as a formatted @@ -1302,3 +1310,4 @@ class Ec2Inventory(object): # Run the script Ec2Inventory() + diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 4dd6a017d7..6733f0d408 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -2,6 +2,7 @@ # All Rights Reserved. import os +import re # noqa import sys import djcelery from datetime import timedelta @@ -217,13 +218,13 @@ REST_FRAMEWORK = { } AUTHENTICATION_BACKENDS = ( - 'awx.main.backend.LDAPBackend', - 'radiusauth.backends.RADIUSBackend', + 'awx.sso.backends.LDAPBackend', + 'awx.sso.backends.RADIUSBackend', 'social.backends.google.GoogleOAuth2', 'social.backends.github.GithubOAuth2', 'social.backends.github.GithubOrganizationOAuth2', 'social.backends.github.GithubTeamOAuth2', - 'social.backends.saml.SAMLAuth', + 'awx.sso.backends.SAMLAuth', 'django.contrib.auth.backends.ModelBackend', ) @@ -355,13 +356,15 @@ SOCIAL_AUTH_PIPELINE = ( 'social.pipeline.social_auth.social_user', 'social.pipeline.user.get_username', 'social.pipeline.social_auth.associate_by_email', - 'social.pipeline.mail.mail_validation', 'social.pipeline.user.create_user', + 'awx.sso.pipeline.check_user_found_or_created', 'social.pipeline.social_auth.associate_user', 'social.pipeline.social_auth.load_extra_data', 'awx.sso.pipeline.set_is_active_for_new_user', 'social.pipeline.user.user_details', 'awx.sso.pipeline.prevent_inactive_login', + 'awx.sso.pipeline.update_user_orgs', + 'awx.sso.pipeline.update_user_teams', ) SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' @@ -370,14 +373,17 @@ SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['profile'] SOCIAL_AUTH_GITHUB_KEY = '' SOCIAL_AUTH_GITHUB_SECRET = '' +SOCIAL_AUTH_GITHUB_SCOPE = ['user:email', 'read:org'] SOCIAL_AUTH_GITHUB_ORG_KEY = '' SOCIAL_AUTH_GITHUB_ORG_SECRET = '' SOCIAL_AUTH_GITHUB_ORG_NAME = '' +SOCIAL_AUTH_GITHUB_ORG_SCOPE = ['user:email', 'read:org'] SOCIAL_AUTH_GITHUB_TEAM_KEY = '' SOCIAL_AUTH_GITHUB_TEAM_SECRET = '' SOCIAL_AUTH_GITHUB_TEAM_ID = '' +SOCIAL_AUTH_GITHUB_TEAM_SCOPE = ['user:email', 'read:org'] SOCIAL_AUTH_SAML_SP_ENTITY_ID = '' SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = '' @@ -400,6 +406,9 @@ SOCIAL_AUTH_CLEAN_USERNAMES = True SOCIAL_AUTH_SANITIZE_REDIRECTS = True SOCIAL_AUTH_REDIRECT_IS_HTTPS = False +SOCIAL_AUTH_ORGANIZATION_MAP = {} +SOCIAL_AUTH_TEAM_MAP = {} + # Any ANSIBLE_* settings will be passed to the subprocess environment by the # celery task. @@ -572,13 +581,19 @@ VMWARE_EXCLUDE_EMPTY_GROUPS = True # provide a list here. # Source: https://developers.google.com/compute/docs/zones GCE_REGION_CHOICES = [ + ('us-east1-b', 'US East (B)'), + ('us-east1-c', 'US East (C)'), + ('us-east1-d', 'US East (D)'), ('us-central1-a', 'US Central (A)'), ('us-central1-b', 'US Central (B)'), + ('us-central1-c', 'US Central (C)'), ('us-central1-f', 'US Central (F)'), - ('europe-west1-a', 'Europe West (A)'), ('europe-west1-b', 'Europe West (B)'), + ('europe-west1-c', 'Europe West (C)'), + ('europe-west1-d', 'Europe West (D)'), ('asia-east1-a', 'Asia East (A)'), ('asia-east1-b', 'Asia East (B)'), + ('asia-east1-c', 'Asia East (C)'), ] GCE_REGIONS_BLACKLIST = [] @@ -715,7 +730,7 @@ LOGGING = { 'level': 'WARNING', 'class':'logging.handlers.RotatingFileHandler', 'filters': ['require_debug_false'], - 'filename': os.path.join(LOG_ROOT, 'tower_warnings.log'), + 'filename': os.path.join(LOG_ROOT, 'tower.log'), 'maxBytes': 1024 * 1024 * 5, # 5 MB 'backupCount': 5, 'formatter':'simple', @@ -807,7 +822,12 @@ LOGGING = { 'propagate': False, }, 'django_auth_ldap': { - 'handlers': ['null'], + 'handlers': ['console', 'file', 'tower_warnings'], + 'level': 'DEBUG', + }, + 'social': { + 'handlers': ['console', 'file', 'tower_warnings'], + 'level': 'DEBUG', }, } } diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index 37d69b7e09..27360a9ecd 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -525,8 +525,80 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = { # 'url': 'https://myidp.example.com/sso', # 'x509cert': '', #}, + #'onelogin': { + # 'entity_id': 'https://app.onelogin.com/saml/metadata/123456', + # 'url': 'https://example.onelogin.com/trust/saml2/http-post/sso/123456', + # 'x509cert': '', + # 'attr_user_permanent_id': 'name_id', + # 'attr_first_name': 'User.FirstName', + # 'attr_last_name': 'User.LastName', + # 'attr_username': 'User.email', + # 'attr_email': 'User.email', + #}, } +SOCIAL_AUTH_ORGANIZATION_MAP = { + # Add all users to the default organization. + 'Default': { + 'users': True, + }, + #'Test Org': { + # 'admins': ['admin@example.com'], + # 'users': True, + #}, + #'Test Org 2': { + # 'admins': ['admin@example.com', re.compile(r'^tower-[^@]+*?@.*$], + # 'users': re.compile(r'^[^@].*?@example\.com$'), + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_SAML_ORGANIZATION_MAP = {} + +SOCIAL_AUTH_TEAM_MAP = { + #'My Team': { + # 'organization': 'Test Org', + # 'users': ['re.compile(r'^[^@]+?@test\.example\.com$')'], + # 'remove': True, + #}, + #'Other Team': { + # 'organization': 'Test Org 2', + # 'users': re.compile(r'^[^@]+?@test2\.example\.com$'), + # 'remove': False, + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP = {} +#SOCIAL_AUTH_SAML_TEAM_MAP = {} + +# Uncomment one or more of the lines below to prevent new user accounts from +# being created for the selected social auth providers. Only users who have +# previously logged in using social auth or have a user account with a matching +# email address will be able to login. + +#SOCIAL_AUTH_USER_FIELDS = [] +#SOCIAL_AUTH_GOOGLE_OAUTH2_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_ORG_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_TEAM_USER_FIELDS = [] +#SOCIAL_AUTH_SAML_USER_FIELDS = [] + +# It is also possible to add custom functions to the social auth pipeline for +# more advanced organization and team mapping. Use at your own risk. + +#def custom_social_auth_pipeline_function(backend, details, user=None, *args, **kwargs): +# print 'custom:', backend, details, user, args, kwargs + +#SOCIAL_AUTH_PIPELINE += ( +# 'awx.settings.development.custom_social_auth_pipeline_function', +#) + ############################################################################### # INVENTORY IMPORT TEST SETTINGS ############################################################################### diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example index b83f5751ff..5ac0dc8ef5 100644 --- a/awx/settings/local_settings.py.example +++ b/awx/settings/local_settings.py.example @@ -523,8 +523,80 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = { # 'url': 'https://myidp.example.com/sso', # 'x509cert': '', #}, + #'onelogin': { + # 'entity_id': 'https://app.onelogin.com/saml/metadata/123456', + # 'url': 'https://example.onelogin.com/trust/saml2/http-post/sso/123456', + # 'x509cert': '', + # 'attr_user_permanent_id': 'name_id', + # 'attr_first_name': 'User.FirstName', + # 'attr_last_name': 'User.LastName', + # 'attr_username': 'User.email', + # 'attr_email': 'User.email', + #}, } +SOCIAL_AUTH_ORGANIZATION_MAP = { + # Add all users to the default organization. + 'Default': { + 'users': True, + }, + #'Test Org': { + # 'admins': ['admin@example.com'], + # 'users': True, + #}, + #'Test Org 2': { + # 'admins': ['admin@example.com', re.compile(r'^tower-[^@]+*?@.*$], + # 'users': re.compile(r'^[^@].*?@example\.com$'), + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_SAML_ORGANIZATION_MAP = {} + +SOCIAL_AUTH_TEAM_MAP = { + #'My Team': { + # 'organization': 'Test Org', + # 'users': ['re.compile(r'^[^@]+?@test\.example\.com$')'], + # 'remove': True, + #}, + #'Other Team': { + # 'organization': 'Test Org 2', + # 'users': re.compile(r'^[^@]+?@test2\.example\.com$'), + # 'remove': False, + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP = {} +#SOCIAL_AUTH_SAML_TEAM_MAP = {} + +# Uncomment one or more of the lines below to prevent new user accounts from +# being created for the selected social auth providers. Only users who have +# previously logged in using social auth or have a user account with a matching +# email address will be able to login. + +#SOCIAL_AUTH_USER_FIELDS = [] +#SOCIAL_AUTH_GOOGLE_OAUTH2_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_ORG_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_TEAM_USER_FIELDS = [] +#SOCIAL_AUTH_SAML_USER_FIELDS = [] + +# It is also possible to add custom functions to the social auth pipeline for +# more advanced organization and team mapping. Use at your own risk. + +#def custom_social_auth_pipeline_function(backend, details, user=None, *args, **kwargs): +# print 'custom:', backend, details, user, args, kwargs + +#SOCIAL_AUTH_PIPELINE += ( +# 'awx.settings.development.custom_social_auth_pipeline_function', +#) + ############################################################################### # INVENTORY IMPORT TEST SETTINGS ############################################################################### diff --git a/awx/settings/postprocess.py b/awx/settings/postprocess.py index a3db97724d..544758e04f 100644 --- a/awx/settings/postprocess.py +++ b/awx/settings/postprocess.py @@ -7,10 +7,10 @@ # settings as needed. if not AUTH_LDAP_SERVER_URI: - AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.main.backend.LDAPBackend'] + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.LDAPBackend'] if not RADIUS_SERVER: - AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'radiusauth.backends.RADIUSBackend'] + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.RADIUSBackend'] if not all([SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET]): AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.google.GoogleOAuth2'] @@ -28,8 +28,7 @@ if not all([SOCIAL_AUTH_SAML_SP_ENTITY_ID, SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, SOCIAL_AUTH_SAML_SP_PRIVATE_KEY, SOCIAL_AUTH_SAML_ORG_INFO, SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, SOCIAL_AUTH_SAML_SUPPORT_CONTACT, SOCIAL_AUTH_SAML_ENABLED_IDPS]): - AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.saml.SAMLAuth'] + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.SAMLAuth'] if not AUTH_BASIC_ENABLED: REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [x for x in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] if x != 'rest_framework.authentication.BasicAuthentication'] - diff --git a/awx/sso/backends.py b/awx/sso/backends.py new file mode 100644 index 0000000000..d033fcab9b --- /dev/null +++ b/awx/sso/backends.py @@ -0,0 +1,232 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python +import logging + +# Django +from django.dispatch import receiver +from django.conf import settings as django_settings + +# django-auth-ldap +from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings +from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend +from django_auth_ldap.backend import populate_user + +# radiusauth +from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend + +# social +from social.backends.saml import OID_USERID +from social.backends.saml import SAMLAuth as BaseSAMLAuth +from social.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvider + +# Ansible Tower +from awx.api.license import feature_enabled + +logger = logging.getLogger('awx.sso.backends') + + +class LDAPSettings(BaseLDAPSettings): + + defaults = dict(BaseLDAPSettings.defaults.items() + { + 'ORGANIZATION_MAP': {}, + 'TEAM_MAP': {}, + }.items()) + + +class LDAPBackend(BaseLDAPBackend): + ''' + Custom LDAP backend for AWX. + ''' + + settings_prefix = 'AUTH_LDAP_' + + def _get_settings(self): + if self._settings is None: + self._settings = LDAPSettings(self.settings_prefix) + return self._settings + + def _set_settings(self, settings): + self._settings = settings + + settings = property(_get_settings, _set_settings) + + def authenticate(self, username, password): + if not self.settings.SERVER_URI: + return None + if not feature_enabled('ldap'): + logger.error("Unable to authenticate, license does not support LDAP authentication") + return None + return super(LDAPBackend, self).authenticate(username, password) + + def get_user(self, user_id): + if not self.settings.SERVER_URI: + return None + if not feature_enabled('ldap'): + logger.error("Unable to get_user, license does not support LDAP authentication") + return None + return super(LDAPBackend, self).get_user(user_id) + + # Disable any LDAP based authorization / permissions checking. + + def has_perm(self, user, perm, obj=None): + return False + + def has_module_perms(self, user, app_label): + return False + + def get_all_permissions(self, user, obj=None): + return set() + + def get_group_permissions(self, user, obj=None): + return set() + + +class RADIUSBackend(BaseRADIUSBackend): + ''' + Custom Radius backend to verify license status + ''' + + def authenticate(self, username, password): + if not django_settings.RADIUS_SERVER: + return None + if not feature_enabled('enterprise_auth'): + logger.error("Unable to authenticate, license does not support RADIUS authentication") + return None + return super(RADIUSBackend, self).authenticate(username, password) + + def get_user(self, user_id): + if not django_settings.RADIUS_SERVER: + return None + if not feature_enabled('enterprise_auth'): + logger.error("Unable to get_user, license does not support RADIUS authentication") + return None + return super(RADIUSBackend, self).get_user(user_id) + + +class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider): + ''' + Custom Identity Provider to make attributes to what we expect. + ''' + + def get_user_permanent_id(self, attributes): + uid = attributes[self.conf.get('attr_user_permanent_id', OID_USERID)] + if isinstance(uid, basestring): + return uid + return uid[0] + + def get_attr(self, attributes, conf_key, default_attribute): + """ + Get the attribute 'default_attribute' out of the attributes, + unless self.conf[conf_key] overrides the default by specifying + another attribute to use. + """ + key = self.conf.get(conf_key, default_attribute) + value = attributes[key][0] if key in attributes else None + if conf_key in ('attr_first_name', 'attr_last_name', 'attr_username', 'attr_email') and value is None: + logger.warn("Could not map user detail '%s' from SAML attribute '%s'; " + "update SOCIAL_AUTH_SAML_ENABLED_IDPS['%s']['%s'] with the correct SAML attribute.", + conf_key[5:], key, self.name, conf_key) + return unicode(value) if value is not None else value + + +class SAMLAuth(BaseSAMLAuth): + ''' + Custom SAMLAuth backend to verify license status + ''' + + def get_idp(self, idp_name): + idp_config = self.setting('ENABLED_IDPS')[idp_name] + return TowerSAMLIdentityProvider(idp_name, **idp_config) + + def authenticate(self, *args, **kwargs): + if not all([django_settings.SOCIAL_AUTH_SAML_SP_ENTITY_ID, django_settings.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, + django_settings.SOCIAL_AUTH_SAML_SP_PRIVATE_KEY, django_settings.SOCIAL_AUTH_SAML_ORG_INFO, + django_settings.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, django_settings.SOCIAL_AUTH_SAML_SUPPORT_CONTACT, + django_settings.SOCIAL_AUTH_SAML_ENABLED_IDPS]): + return None + if not feature_enabled('enterprise_auth'): + logger.error("Unable to authenticate, license does not support SAML authentication") + return None + return super(SAMLAuth, self).authenticate(*args, **kwargs) + + def get_user(self, user_id): + if not all([django_settings.SOCIAL_AUTH_SAML_SP_ENTITY_ID, django_settings.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, + django_settings.SOCIAL_AUTH_SAML_SP_PRIVATE_KEY, django_settings.SOCIAL_AUTH_SAML_ORG_INFO, + django_settings.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, django_settings.SOCIAL_AUTH_SAML_SUPPORT_CONTACT, + django_settings.SOCIAL_AUTH_SAML_ENABLED_IDPS]): + return None + if not feature_enabled('enterprise_auth'): + logger.error("Unable to get_user, license does not support SAML authentication") + return None + return super(SAMLAuth, self).get_user(user_id) + + +def _update_m2m_from_groups(user, ldap_user, rel, opts, remove=False): + ''' + Hepler function to update m2m relationship based on LDAP group membership. + ''' + should_add = False + if opts is None: + return + elif not opts: + pass + elif opts is True: + should_add = True + else: + if isinstance(opts, basestring): + opts = [opts] + for group_dn in opts: + if not isinstance(group_dn, basestring): + continue + if ldap_user._get_groups().is_member_of(group_dn): + should_add = True + if should_add: + rel.add(user) + elif remove: + rel.remove(user) + + +@receiver(populate_user) +def on_populate_user(sender, **kwargs): + ''' + Handle signal from LDAP backend to populate the user object. Update user + organization/team memberships according to their LDAP groups. + ''' + from awx.main.models import Organization, Team + user = kwargs['user'] + ldap_user = kwargs['ldap_user'] + backend = ldap_user.backend + + # Update organization membership based on group memberships. + org_map = getattr(backend.settings, 'ORGANIZATION_MAP', {}) + for org_name, org_opts in org_map.items(): + org, created = Organization.objects.get_or_create(name=org_name) + remove = bool(org_opts.get('remove', False)) + admins_opts = org_opts.get('admins', None) + remove_admins = bool(org_opts.get('remove_admins', remove)) + _update_m2m_from_groups(user, ldap_user, org.admins, admins_opts, + remove_admins) + users_opts = org_opts.get('users', None) + remove_users = bool(org_opts.get('remove_users', remove)) + _update_m2m_from_groups(user, ldap_user, org.users, users_opts, + remove_users) + + # Update team membership based on group memberships. + team_map = getattr(backend.settings, 'TEAM_MAP', {}) + for team_name, team_opts in team_map.items(): + if 'organization' not in team_opts: + continue + org, created = Organization.objects.get_or_create(name=team_opts['organization']) + team, created = Team.objects.get_or_create(name=team_name, organization=org) + users_opts = team_opts.get('users', None) + remove = bool(team_opts.get('remove', False)) + _update_m2m_from_groups(user, ldap_user, team.users, users_opts, + remove) + + # Update user profile to store LDAP DN. + profile = user.profile + if profile.ldap_dn != ldap_user.dn: + profile.ldap_dn = ldap_user.dn + profile.save() diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py index 9923680cb2..012bcefd55 100644 --- a/awx/sso/middleware.py +++ b/awx/sso/middleware.py @@ -17,13 +17,13 @@ from social.exceptions import SocialAuthBaseException from social.utils import social_logger from social.apps.django_app.middleware import SocialAuthExceptionMiddleware +# Ansible Tower from awx.main.models import AuthToken + class SocialAuthMiddleware(SocialAuthExceptionMiddleware): def process_request(self, request): - request.META['SERVER_PORT'] = 80 # FIXME - token_key = request.COOKIES.get('token', '') token_key = urllib.quote(urllib.unquote(token_key).strip('"')) @@ -63,7 +63,7 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware): if strategy is None or self.raise_exception(request, exception): return - if isinstance(exception, SocialAuthBaseException): + if isinstance(exception, SocialAuthBaseException) or request.path.startswith('/sso/'): backend = getattr(request, 'backend', None) backend_name = getattr(backend, 'name', 'unknown-backend') full_backend_name = backend_name diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index e11b115cd6..7000d050ee 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -1,17 +1,38 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +# Python +import re + # Python Social Auth from social.exceptions import AuthException +# Tower +from awx.api.license import feature_enabled + + +class AuthNotFound(AuthException): + + def __init__(self, backend, email_or_uid, *args, **kwargs): + self.email_or_uid = email_or_uid + super(AuthNotFound, self).__init__(backend, *args, **kwargs) + + def __str__(self): + return 'An account cannot be found for {0}'.format(self.email_or_uid) + class AuthInactive(AuthException): - """Authentication for this user is forbidden""" def __str__(self): return 'Your account is inactive' +def check_user_found_or_created(backend, details, user=None, *args, **kwargs): + if not user: + email_or_uid = details.get('email') or kwargs.get('email') or kwargs.get('uid') or '???' + raise AuthNotFound(backend, email_or_uid) + + def set_is_active_for_new_user(strategy, details, user=None, *args, **kwargs): if kwargs.get('is_new', False): details['is_active'] = True @@ -21,3 +42,96 @@ def set_is_active_for_new_user(strategy, details, user=None, *args, **kwargs): def prevent_inactive_login(backend, details, user=None, *args, **kwargs): if user and not user.is_active: raise AuthInactive(backend) + + +def _update_m2m_from_expression(user, rel, expr, remove=False): + ''' + Helper function to update m2m relationship based on user matching one or + more expressions. + ''' + should_add = False + if expr is None: + return + elif not expr: + pass + elif expr is True: + should_add = True + else: + if isinstance(expr, (basestring, type(re.compile('')))): + expr = [expr] + for ex in expr: + if isinstance(ex, basestring): + if user.username == ex or user.email == ex: + should_add = True + elif isinstance(ex, type(re.compile(''))): + if ex.match(user.username) or ex.match(user.email): + should_add = True + if should_add: + rel.add(user) + elif remove: + rel.remove(user) + + +def update_user_orgs(backend, details, user=None, *args, **kwargs): + ''' + Update organization memberships for the given user based on mapping rules + defined in settings. + ''' + if not user: + return + from awx.main.models import Organization + multiple_orgs = feature_enabled('multiple_organizations') + org_map = backend.setting('ORGANIZATION_MAP') or {} + for org_name, org_opts in org_map.items(): + + # Get or create the org to update. If the license only allows for one + # org, always use the first active org, unless no org exists. + if multiple_orgs: + org = Organization.objects.get_or_create(name=org_name)[0] + else: + try: + org = Organization.objects.filter(active=True).order_by('pk')[0] + except IndexError: + continue + + # Update org admins from expression(s). + remove = bool(org_opts.get('remove', False)) + admins_expr = org_opts.get('admins', None) + remove_admins = bool(org_opts.get('remove_admins', remove)) + _update_m2m_from_expression(user, org.admins, admins_expr, remove_admins) + + # Update org users from expression(s). + users_expr = org_opts.get('users', None) + remove_users = bool(org_opts.get('remove_users', remove)) + _update_m2m_from_expression(user, org.users, users_expr, remove_users) + + +def update_user_teams(backend, details, user=None, *args, **kwargs): + ''' + Update team memberships for the given user based on mapping rules defined + in settings. + ''' + if not user: + return + from awx.main.models import Organization, Team + multiple_orgs = feature_enabled('multiple_organizations') + team_map = backend.setting('TEAM_MAP') or {} + for team_name, team_opts in team_map.items(): + + # Get or create the org to update. If the license only allows for one + # org, always use the first active org, unless no org exists. + if multiple_orgs: + if 'organization' not in team_opts: + continue + org = Organization.objects.get_or_create(name=team_opts['organization'])[0] + else: + try: + org = Organization.objects.filter(active=True).order_by('pk')[0] + except IndexError: + continue + + # Update team members from expression(s). + team = Team.objects.get_or_create(name=team_name, organization=org)[0] + users_expr = team_opts.get('users', None) + remove = bool(team_opts.get('remove', False)) + _update_m2m_from_expression(user, team.users, users_expr, remove) diff --git a/awx/ui/client/assets/cowsay-about.html b/awx/ui/client/assets/cowsay-about.html index ad38bb572d..b1cbcf9d24 100644 --- a/awx/ui/client/assets/cowsay-about.html +++ b/awx/ui/client/assets/cowsay-about.html @@ -1,7 +1,7 @@
-
+
  ________________
@@ -19,10 +19,11 @@
         

Copyright 2015. All rights reserved.

-

Ansible and Ansible Tower are registered trademarks of Ansible, Inc.

+

Ansible and Ansible Tower are registered trademarks of Red Hat, Inc.

+
+

Visit Ansible.com for more information.

-

diff --git a/awx/ui/client/src/about/about.block.less b/awx/ui/client/src/about/about.block.less new file mode 100644 index 0000000000..4e46b24b50 --- /dev/null +++ b/awx/ui/client/src/about/about.block.less @@ -0,0 +1,14 @@ +/** @define About */ +.About { + height: 309px !important; +} + +.About-cowsay { + margin-top: 30px; +} + +.About-redhat { + max-width: 100%; + margin-top: -61px; + margin-bottom: -33px; +} diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index a50dbb0103..1fa786a998 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -837,9 +837,9 @@ var tower = angular.module('Tower', [ }]); }]) - .run(['$compile', '$cookieStore', '$rootScope', '$log', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'HideStream', 'Socket', + .run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'HideStream', 'Socket', 'LoadConfig', 'Store', 'ShowSocketHelp', 'AboutAnsibleHelp', 'pendoService', - function ($compile, $cookieStore, $rootScope, $log, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, HideStream, Socket, + function ($q, $compile, $cookieStore, $rootScope, $log, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, HideStream, Socket, LoadConfig, Store, ShowSocketHelp, AboutAnsibleHelp, pendoService) { @@ -950,7 +950,8 @@ var tower = angular.module('Tower', [ control_socket.init(); control_socket.on("limit_reached", function(data) { $log.debug(data.reason); - Timer.expireSession('session_limit'); + $rootScope.sessionTimer.expireSession('session_limit'); + $location.url('/login'); }); } openSocket(); @@ -999,7 +1000,7 @@ var tower = angular.module('Tower', [ // gets here on timeout if (next.templateUrl !== (urlPrefix + 'login/loginBackDrop.partial.html')) { $rootScope.sessionTimer.expireSession('idle'); - if (sock) { + if (sock&& sock.socket && sock.socket.socket) { sock.socket.socket.disconnect(); } $location.path('/login'); @@ -1026,9 +1027,11 @@ var tower = angular.module('Tower', [ $rootScope.user_is_superuser = Authorization.getUserInfo('is_superuser'); // when the user refreshes we want to open the socket, except if the user is on the login page, which should happen after the user logs in (see the AuthService module for that call to OpenSocket) if(!_.contains($location.$$url, '/login')){ - $rootScope.sessionTimer = Timer.init(); - $rootScope.$emit('OpenSocket'); - pendoService.issuePendoIdentity(); + Timer.init().then(function(timer){ + $rootScope.sessionTimer = timer; + $rootScope.$emit('OpenSocket'); + pendoService.issuePendoIdentity(); + }); } } @@ -1063,8 +1066,8 @@ var tower = angular.module('Tower', [ if (!$AnsibleConfig) { - // there may be time lag when loading the config file, so temporarily use what's in local storage - $AnsibleConfig = Store('AnsibleConfig'); + // create a promise that will resolve when $AnsibleConfig is loaded + $rootScope.loginConfig = $q.defer(); } //the authorization controller redirects to the home page automatcially if there is no last path defined. in order to override diff --git a/awx/ui/client/src/controllers/JobDetail.js b/awx/ui/client/src/controllers/JobDetail.js index cd31c73707..2aea1525d9 100644 --- a/awx/ui/client/src/controllers/JobDetail.js +++ b/awx/ui/client/src/controllers/JobDetail.js @@ -13,7 +13,7 @@ export function JobDetailController ($location, $rootScope, $filter, $scope, $compile, $routeParams, $log, ClearScope, Breadcrumbs, LoadBreadCrumbs, GetBasePath, Wait, Rest, ProcessErrors, SelectPlay, SelectTask, Socket, GetElapsed, DrawGraph, LoadHostSummary, ReloadHostSummaryList, JobIsFinished, SetTaskStyles, DigestEvent, - UpdateDOM, EventViewer, DeleteJob, PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts, HostsEdit, ParseVariableString, GetChoices) { + UpdateDOM, EventViewer, DeleteJob, PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts, HostsEdit, ParseVariableString, GetChoices, fieldChoices, fieldLabels) { ClearScope(); @@ -27,11 +27,33 @@ export function JobDetailController ($location, $rootScope, $filter, $scope, $co scope.plays = []; + scope.previousTaskFailed = false; + scope.$watch('job_status', function(job_status) { if (job_status && job_status.explanation && job_status.explanation.split(":")[0] === "Previous Task Failed") { + scope.previousTaskFailed = true; var taskObj = JSON.parse(job_status.explanation.substring(job_status.explanation.split(":")[0].length + 1)); - job_status.explanation = job_status.explanation.split(":")[0] + ". "; - job_status.explanation += "" + taskObj.task_type + "-" + taskObj.task_id + " failed for " + taskObj.task_name + "" + // return a promise from the options request with the permission type choices (including adhoc) as a param + var fieldChoice = fieldChoices({ + scope: $scope, + url: 'api/v1/unified_jobs/', + field: 'type' + }); + + // manipulate the choices from the options request to be set on + // scope and be usable by the list form + fieldChoice.then(function (choices) { + choices = + fieldLabels({ + choices: choices + }); + scope.explanation_fail_type = choices[taskObj.job_type]; + scope.explanation_fail_name = taskObj.job_name; + scope.explanation_fail_id = taskObj.job_id; + scope.task_detail = scope.explanation_fail_type + " failed for " + scope.explanation_fail_name + " with ID " + scope.explanation_fail_id + "."; + }); + } else { + scope.previousTaskFailed = false; } }, true); @@ -649,61 +671,6 @@ export function JobDetailController ($location, $rootScope, $filter, $scope, $co }); - if (scope.removeGetCredentialNames) { - scope.removeGetCredentialNames(); - } - scope.removeGetCredentialNames = scope.$on('GetCredentialNames', function(e, data) { - var url; - if (data.credential) { - url = GetBasePath('credentials') + data.credential + '/'; - Rest.setUrl(url); - Rest.get() - .success( function(data) { - scope.credential_name = data.name; - }) - .error( function(data, status) { - scope.credential_name = ''; - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); - }); - } - if (data.cloud_credential) { - url = GetBasePath('credentials') + data.cloud_credential + '/'; - Rest.setUrl(url); - Rest.get() - .success( function(data) { - scope.cloud_credential_name = data.name; - }) - .error( function(data, status) { - scope.credential_name = ''; - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); - }); - } - }); - - if (scope.removeGetCreatedByNames) { - scope.removeGetCreatedByNames(); - } - scope.removeGetCreatedByNames = scope.$on('GetCreatedByNames', function(e, data) { - var url; - data = data.slice(0, data.length-1); - data = data.slice(data.lastIndexOf('/')+1, data.length); - url = GetBasePath('users') + data + '/'; - scope.users_url = '/#/users/' + data; - Rest.setUrl(url); - Rest.get() - .success( function(data) { - scope.created_by = data.username; - }) - .error( function(data, status) { - scope.credential_name = ''; - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); - }); - }); - - if (scope.removeLoadJob) { scope.removeLoadJob(); } @@ -738,6 +705,24 @@ export function JobDetailController ($location, $rootScope, $filter, $scope, $co scope.verbosity = data.verbosity; scope.job_tags = data.job_tags; scope.variables = ParseVariableString(data.extra_vars); + scope.users_url = (data.summary_fields.created_by) ? '/#/users/' + data.summary_fields.created_by.id : ''; + scope.created_by = (data.summary_fields.created_by) ? data.summary_fields.created_by.username : ''; + + if (data.summary_fields.credential) { + scope.credential_name = data.summary_fields.credential.name; + scope.credential_url = data.related.credential + .replace('api/v1', '#'); + } else { + scope.credential_name = ""; + } + + if (data.summary_fields.cloud_credential) { + scope.cloud_credential_name = data.summary_fields.cloud_credential.name; + scope.cloud_credential_url = data.related.cloud_credential + .replace('api/v1', '#'); + } else { + scope.cloud_credential_name = ""; + } for (i=0; i < verbosity_options.length; i++) { if (verbosity_options[i].value === data.verbosity) { @@ -792,10 +777,6 @@ export function JobDetailController ($location, $rootScope, $filter, $scope, $co }); //scope.setSearchAll('host'); scope.$emit('LoadPlays', data.related.job_events); - scope.$emit('GetCreatedByNames', data.related.created_by); - if (!scope.credential_name) { - scope.$emit('GetCredentialNames', data); - } }) .error(function(data, status) { ProcessErrors(scope, data, status, null, { hdr: 'Error!', @@ -1415,5 +1396,5 @@ export function JobDetailController ($location, $rootScope, $filter, $scope, $co JobDetailController.$inject = [ '$location', '$rootScope', '$filter', '$scope', '$compile', '$routeParams', '$log', 'ClearScope', 'Breadcrumbs', 'LoadBreadCrumbs', 'GetBasePath', 'Wait', 'Rest', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed', 'DrawGraph', 'LoadHostSummary', 'ReloadHostSummaryList', 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'EventViewer', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer', 'LoadPlays', 'LoadTasks', - 'LoadHosts', 'HostsEdit', 'ParseVariableString', 'GetChoices' + 'LoadHosts', 'HostsEdit', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels' ]; diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 281d6bb0ad..4083dbe2d4 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -13,7 +13,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $routeParams, Rest, Alert, ProjectList, GenerateList, LoadBreadCrumbs, Prompt, SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, SelectionInit, ProjectUpdate, - Refresh, Wait, Stream, GetChoices, Empty, Find, LogViewer, GetProjectIcon, GetProjectToolTip) { + Refresh, Wait, Stream, GetChoices, Empty, Find, LogViewer, GetProjectIcon, GetProjectToolTip, $filter) { ClearScope(); @@ -309,29 +309,37 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $routeParams, }); $scope.cancelUpdate = function (id, name) { - // Start the cancel process - var i, project, found = false; - for (i = 0; i < $scope.projects.length; i++) { - if ($scope.projects[i].id === id) { - project = $scope.projects[i]; - found = true; - break; - } - } - if (found && project.related.current_update) { - Rest.setUrl(project.related.current_update); - Rest.get() - .success(function (data) { - $scope.$emit('Check_Cancel', data); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + project.related.current_update + ' failed. GET status: ' + status }); - }); - } else { - Alert('Update Not Found', 'An SCM update does not appear to be running for project: ' + name + '. Click the Refresh ' + - 'button to view the latet status.', 'alert-info'); - } + // // Start the cancel process + // var i, project, found = false; + // for (i = 0; i < $scope.projects.length; i++) { + // if ($scope.projects[i].id === id) { + // project = $scope.projects[i]; + // found = true; + // break; + // } + // } + Rest.setUrl(GetBasePath("projects") + id); + Rest.get() + .success(function (data) { + if (data.related.current_update) { + Rest.setUrl(data.related.current_update); + Rest.get() + .success(function (data) { + $scope.$emit('Check_Cancel', data); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + data.related.current_update + ' failed. GET status: ' + status }); + }); + } else { + Alert('Update Not Found', 'An SCM update does not appear to be running for project: ' + $filter('sanitize')(name) + '. Click the Refresh ' + + 'button to view the latest status.', 'alert-info',undefined,undefined,undefined,undefined,true); + } + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Call to get project failed. GET status: ' + status }); + }) }; $scope.refresh = function () { @@ -384,7 +392,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $routeParams, ProjectsList.$inject = ['$scope', '$rootScope', '$location', '$log', '$routeParams', 'Rest', 'Alert', 'ProjectList', 'generateList', 'LoadBreadCrumbs', 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', 'GetBasePath', 'SelectionInit', 'ProjectUpdate', 'Refresh', 'Wait', 'Stream', 'GetChoices', 'Empty', 'Find', - 'LogViewer', 'GetProjectIcon', 'GetProjectToolTip' + 'LogViewer', 'GetProjectIcon', 'GetProjectToolTip', '$filter' ]; diff --git a/awx/ui/client/src/controllers/Teams.js b/awx/ui/client/src/controllers/Teams.js index ab5dc9298f..1cc8408bd1 100644 --- a/awx/ui/client/src/controllers/Teams.js +++ b/awx/ui/client/src/controllers/Teams.js @@ -176,7 +176,7 @@ TeamsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', '$r export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $routeParams, TeamForm, GenerateForm, Rest, Alert, ProcessErrors, LoadBreadCrumbs, RelatedSearchInit, RelatedPaginateInit, ReturnToCaller, ClearScope, LookUpInit, Prompt, GetBasePath, CheckAccess, - OrganizationList, Wait, Stream, permissionsChoices, permissionsLabel, permissionsSearchSelect) { + OrganizationList, Wait, Stream, fieldChoices, fieldLabels, permissionsSearchSelect) { ClearScope(); @@ -192,16 +192,17 @@ export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $routeP $scope.permission_search_select = []; // return a promise from the options request with the permission type choices (including adhoc) as a param - var permissionsChoice = permissionsChoices({ + var permissionsChoice = fieldChoices({ scope: $scope, - url: 'api/v1/' + base + '/' + id + '/permissions/' + url: 'api/v1/' + base + '/' + id + '/permissions/', + field: 'permission_type' }); // manipulate the choices from the options request to be set on // scope and be usable by the list form permissionsChoice.then(function (choices) { choices = - permissionsLabel({ + fieldLabels({ choices: choices }); _.map(choices, function(n, key) { @@ -209,8 +210,6 @@ export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $routeP }); }); - $scope.team_id = id; - // manipulate the choices from the options request to be usable // by the search option for permission_type, you can't inject the // list until this is done! @@ -221,8 +220,11 @@ export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $routeP }); generator.inject(form, { mode: 'edit', related: true, scope: $scope }); generator.reset(); + $scope.$emit('loadTeam'); }); + $scope.team_id = id; + $scope.PermissionAddAllowed = false; // Retrieve each related set and any lookups @@ -253,59 +255,65 @@ export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $routeP } }); - // Retrieve detail record and prepopulate the form - Wait('start'); - Rest.setUrl(defaultUrl + ':id/'); - Rest.get({ - params: { - id: id - } - }) - .success(function (data) { - var fld, related, set; - $scope.team_name = data.name; - for (fld in form.fields) { - if (data[fld]) { - $scope[fld] = data[fld]; - master[fld] = $scope[fld]; - } + // Retrieve each related set and any lookups + if ($scope.loadTeamRemove) { + $scope.loadTeamRemove(); + } + $scope.loadTeamRemove = $scope.$on('loadTeam', function () { + // Retrieve detail record and prepopulate the form + Wait('start'); + Rest.setUrl(defaultUrl + ':id/'); + Rest.get({ + params: { + id: id } - related = data.related; - for (set in form.related) { - if (related[set]) { - relatedSets[set] = { - url: related[set], - iterator: form.related[set].iterator - }; - } - } - // Initialize related search functions. Doing it here to make sure relatedSets object is populated. - RelatedSearchInit({ - scope: $scope, - form: form, - relatedSets: relatedSets - }); - RelatedPaginateInit({ - scope: $scope, - relatedSets: relatedSets - }); - - LookUpInit({ - scope: $scope, - form: form, - current_item: data.organization, - list: OrganizationList, - field: 'organization', - input_type: 'radio' - }); - - $scope.organization_url = data.related.organization; - $scope.$emit('teamLoaded'); }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to retrieve team: ' + $routeParams.team_id + - '. GET status: ' + status }); - }); + .success(function (data) { + var fld, related, set; + $scope.team_name = data.name; + for (fld in form.fields) { + if (data[fld]) { + $scope[fld] = data[fld]; + master[fld] = $scope[fld]; + } + } + related = data.related; + for (set in form.related) { + if (related[set]) { + relatedSets[set] = { + url: related[set], + iterator: form.related[set].iterator + }; + } + } + // Initialize related search functions. Doing it here to make sure relatedSets object is populated. + RelatedSearchInit({ + scope: $scope, + form: form, + relatedSets: relatedSets + }); + RelatedPaginateInit({ + scope: $scope, + relatedSets: relatedSets + }); + + LookUpInit({ + scope: $scope, + form: form, + current_item: data.organization, + list: OrganizationList, + field: 'organization', + input_type: 'radio' + }); + + $scope.organization_url = data.related.organization; + $scope.$emit('teamLoaded'); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to retrieve team: ' + $routeParams.team_id + + '. GET status: ' + status }); + }); + }); $scope.getPermissionText = function () { if (this.permission.permission_type !== "admin" && this.permission.run_ad_hoc_commands) { @@ -431,5 +439,5 @@ export function TeamsEdit($scope, $rootScope, $compile, $location, $log, $routeP TeamsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', '$routeParams', 'TeamForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'LoadBreadCrumbs', 'RelatedSearchInit', 'RelatedPaginateInit', - 'ReturnToCaller', 'ClearScope', 'LookUpInit', 'Prompt', 'GetBasePath', 'CheckAccess', 'OrganizationList', 'Wait', 'Stream', 'permissionsChoices', 'permissionsLabel', 'permissionsSearchSelect' + 'ReturnToCaller', 'ClearScope', 'LookUpInit', 'Prompt', 'GetBasePath', 'CheckAccess', 'OrganizationList', 'Wait', 'Stream', 'fieldChoices', 'fieldLabels', 'permissionsSearchSelect' ]; diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js index 2a37115a16..9cd0d2d161 100644 --- a/awx/ui/client/src/controllers/Users.js +++ b/awx/ui/client/src/controllers/Users.js @@ -208,7 +208,7 @@ UsersAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', '$r export function UsersEdit($scope, $rootScope, $compile, $location, $log, $routeParams, UserForm, GenerateForm, Rest, Alert, ProcessErrors, LoadBreadCrumbs, RelatedSearchInit, RelatedPaginateInit, ReturnToCaller, ClearScope, GetBasePath, - Prompt, CheckAccess, ResetForm, Wait, Stream, permissionsChoices, permissionsLabel, permissionsSearchSelect) { + Prompt, CheckAccess, ResetForm, Wait, Stream, fieldChoices, fieldLabels, permissionsSearchSelect) { ClearScope(); @@ -224,16 +224,17 @@ export function UsersEdit($scope, $rootScope, $compile, $location, $log, $routeP $scope.permission_search_select = []; // return a promise from the options request with the permission type choices (including adhoc) as a param - var permissionsChoice = permissionsChoices({ + var permissionsChoice = fieldChoices({ scope: $scope, - url: 'api/v1/' + base + '/' + id + '/permissions/' + url: 'api/v1/' + base + '/' + id + '/permissions/', + field: 'permission_type' }); // manipulate the choices from the options request to be set on // scope and be usable by the list form permissionsChoice.then(function (choices) { choices = - permissionsLabel({ + fieldLabels({ choices: choices }); _.map(choices, function(n, key) { @@ -241,22 +242,23 @@ export function UsersEdit($scope, $rootScope, $compile, $location, $log, $routeP }); }); + // manipulate the choices from the options request to be usable + // by the search option for permission_type, you can't inject the + // list until this is done! + permissionsChoice.then(function (choices) { + form.related.permissions.fields.permission_type.searchOptions = + permissionsSearchSelect({ + choices: choices + }); + generator.inject(form, { mode: 'edit', related: true, scope: $scope }); + generator.reset(); + $scope.$emit("loadForm"); + }); + if ($scope.removeFormReady) { $scope.removeFormReady(); } $scope.removeFormReady = $scope.$on('formReady', function () { - // manipulate the choices from the options request to be usable - // by the search option for permission_type, you can't inject the - // list until this is done! - permissionsChoice.then(function (choices) { - form.related.permissions.fields.permission_type.searchOptions = - permissionsSearchSelect({ - choices: choices - }); - generator.inject(form, { mode: 'edit', related: true, scope: $scope }); - generator.reset(); - }); - if ($scope.removePostRefresh) { $scope.removePostRefresh(); } @@ -470,54 +472,60 @@ export function UsersEdit($scope, $rootScope, $compile, $location, $log, $routeP // Put form back to its original state ResetForm(); - - if ($scope.removeModifyForm) { - $scope.removeModifyForm(); + if ($scope.removeLoadForm) { + $scope.removeLoadForm(); } - $scope.removeModifyForm = $scope.$on('modifyForm', function () { - // Modify form based on LDAP settings - Rest.setUrl(GetBasePath('config')); - Rest.get() - .success(function (data) { - var i, fld; - if (data.user_ldap_fields) { - for (i = 0; i < data.user_ldap_fields.length; i++) { - fld = data.user_ldap_fields[i]; - if (form.fields[fld]) { - form.fields[fld].readonly = true; - form.fields[fld].editRequired = false; - if (form.fields[fld].awRequiredWhen) { - delete form.fields[fld].awRequiredWhen; + $scope.removeLoadForm = $scope.$on('loadForm', function () { + + + if ($scope.removeModifyForm) { + $scope.removeModifyForm(); + } + $scope.removeModifyForm = $scope.$on('modifyForm', function () { + // Modify form based on LDAP settings + Rest.setUrl(GetBasePath('config')); + Rest.get() + .success(function (data) { + var i, fld; + if (data.user_ldap_fields) { + for (i = 0; i < data.user_ldap_fields.length; i++) { + fld = data.user_ldap_fields[i]; + if (form.fields[fld]) { + form.fields[fld].readonly = true; + form.fields[fld].editRequired = false; + if (form.fields[fld].awRequiredWhen) { + delete form.fields[fld].awRequiredWhen; + } } } } + $scope.$emit('formReady'); + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', + msg: 'Failed to retrieve application config. GET status: ' + status }); + }); + }); + + Wait('start'); + Rest.setUrl(defaultUrl + id + '/'); + Rest.get() + .success(function (data) { + if (data.ldap_dn !== null && data.ldap_dn !== undefined && data.ldap_dn !== '') { + //this is an LDAP user + $scope.$emit('modifyForm'); + } else { + $scope.$emit('formReady'); } - $scope.$emit('formReady'); }) .error(function (data, status) { ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve application config. GET status: ' + status }); + msg: 'Failed to retrieve user: ' + id + '. GET status: ' + status }); }); }); - - Wait('start'); - Rest.setUrl(defaultUrl + id + '/'); - Rest.get() - .success(function (data) { - if (data.ldap_dn !== null && data.ldap_dn !== undefined && data.ldap_dn !== '') { - //this is an LDAP user - $scope.$emit('modifyForm'); - } else { - $scope.$emit('formReady'); - } - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve user: ' + id + '. GET status: ' + status }); - }); } UsersEdit.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', '$routeParams', 'UserForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'LoadBreadCrumbs', 'RelatedSearchInit', 'RelatedPaginateInit', 'ReturnToCaller', 'ClearScope', - 'GetBasePath', 'Prompt', 'CheckAccess', 'ResetForm', 'Wait', 'Stream', 'permissionsChoices', 'permissionsLabel', 'permissionsSearchSelect' + 'GetBasePath', 'Prompt', 'CheckAccess', 'ResetForm', 'Wait', 'Stream', 'fieldChoices', 'fieldLabels', 'permissionsSearchSelect' ]; diff --git a/awx/ui/client/src/forms/Source.js b/awx/ui/client/src/forms/Source.js index 9feb8e7f28..19780c3df0 100644 --- a/awx/ui/client/src/forms/Source.js +++ b/awx/ui/client/src/forms/Source.js @@ -118,8 +118,8 @@ export default sourceModel: 'inventory_script', sourceField: 'name', ngClick: 'lookUpInventory_script()' , - addRequired: false, - editRequired: false, + addRequired: true, + editRequired: true, ngRequired: "source && source.value === 'custom'", }, extra_vars: { diff --git a/awx/ui/client/src/helpers/AboutAnsible.js b/awx/ui/client/src/helpers/AboutAnsible.js index 68fda8fc92..69fef1236c 100644 --- a/awx/ui/client/src/helpers/AboutAnsible.js +++ b/awx/ui/client/src/helpers/AboutAnsible.js @@ -75,7 +75,7 @@ export default scope: scope, // buttons: [], width: 710, - height: 400, + height: 450, minWidth: 300, resizable: false, callback: 'DialogReady', diff --git a/awx/ui/client/src/helpers/Groups.js b/awx/ui/client/src/helpers/Groups.js index 63fe00cff1..4d7fb0487c 100644 --- a/awx/ui/client/src/helpers/Groups.js +++ b/awx/ui/client/src/helpers/Groups.js @@ -751,9 +751,9 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name $compile(elem)(modal_scope); var form_scope = - generator.inject(GroupForm, { mode: 'edit', id: 'properties-tab', breadCrumbs: false, related: false, scope: properties_scope }); + generator.inject(GroupForm, { mode: mode, id: 'properties-tab', breadCrumbs: false, related: false, scope: properties_scope }); var source_form_scope = - generator.inject(SourceForm, { mode: 'edit', id: 'sources-tab', breadCrumbs: false, related: false, scope: sources_scope }); + generator.inject(SourceForm, { mode: mode, id: 'sources-tab', breadCrumbs: false, related: false, scope: sources_scope }); //generator.reset(); @@ -1465,6 +1465,11 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name // Change the lookup and regions when the source changes sources_scope.sourceChange = function () { + sources_scope.credential_name = ""; + sources_scope.credential = ""; + if (sources_scope.credential_name_api_error) { + delete sources_scope.credential_name_api_error; + } parent_scope.showSchedulesTab = (mode === 'edit' && sources_scope.source && sources_scope.source.value!=="manual") ? true : false; SourceChange({ scope: sources_scope, form: SourceForm }); }; diff --git a/awx/ui/client/src/helpers/License.js b/awx/ui/client/src/helpers/License.js index 66c40a64fe..f4d99fe0a3 100644 --- a/awx/ui/client/src/helpers/License.js +++ b/awx/ui/client/src/helpers/License.js @@ -25,9 +25,11 @@ export default .factory('CheckLicense', ['$rootScope', '$compile', 'CreateDialog', 'Store', 'LicenseUpdateForm', 'GenerateForm', 'TextareaResize', 'ToJSON', 'GetBasePath', - 'Rest', 'ProcessErrors', 'Alert', 'IsAdmin', '$location', + 'Rest', 'ProcessErrors', 'Alert', 'IsAdmin', '$location', 'pendoService', + 'Authorization', 'Wait', function($rootScope, $compile, CreateDialog, Store, LicenseUpdateForm, GenerateForm, - TextareaResize, ToJSON, GetBasePath, Rest, ProcessErrors, Alert, IsAdmin, $location) { + TextareaResize, ToJSON, GetBasePath, Rest, ProcessErrors, Alert, IsAdmin, $location, + pendoService, Authorization, Wait) { return { getRemainingDays: function(time_remaining) { // assumes time_remaining will be in seconds @@ -164,21 +166,32 @@ export default if (typeof json_data === 'object' && Object.keys(json_data).length > 0) { Rest.setUrl(url); Rest.post(json_data) - .success(function () { - try { - $('#license-modal-dialog').dialog('close'); - } - catch(e) { - // ignore - } + .success(function (response) { + response.license_info = response; Alert('License Accepted', 'The Ansible Tower license was updated. To review or update the license, choose View License from the Setup menu.','alert-info'); $rootScope.features = undefined; - $location.path('/home'); + + Authorization.getLicense() + .success(function (data) { + Authorization.setLicense(data); + pendoService.issuePendoIdentity(); + Wait("stop"); + $location.path('/home'); + }) + .error(function () { + Wait('stop'); + Alert('Error', 'Failed to access license information. GET returned status: ' + status, 'alert-danger', + $location.path('/logout')); + }); + + + + }) - .error(function (data, status) { + .catch(function (response) { scope.license_json_api_error = "A valid license key in JSON format is required"; - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to update license. POST returned: ' + status + ProcessErrors(scope, response.data, response.status, null, { hdr: 'Error!', + msg: 'Failed to update license. POST returned: ' + response.status }); }); } else { diff --git a/awx/ui/client/src/helpers/LoadConfig.js b/awx/ui/client/src/helpers/LoadConfig.js index 8c4245697e..ac3d1d54ac 100644 --- a/awx/ui/client/src/helpers/LoadConfig.js +++ b/awx/ui/client/src/helpers/LoadConfig.js @@ -39,16 +39,25 @@ angular.module('LoadConfigHelper', ['Utilities']) if(angular.isObject(response.data)){ $AnsibleConfig = _.extend($AnsibleConfig, response.data); Store('AnsibleConfig', $AnsibleConfig); + if ($rootScope.loginConfig) { + $rootScope.loginConfig.resolve('config loaded'); + } $rootScope.$emit('ConfigReady'); } else { $log.info('local_settings.json is not a valid object'); + if ($rootScope.loginConfig) { + $rootScope.loginConfig.resolve('config loaded'); + } $rootScope.$emit('ConfigReady'); } }, function() { //local_settings.json not found $log.info('local_settings.json not found'); + if ($rootScope.loginConfig) { + $rootScope.loginConfig.resolve('config loaded'); + } $rootScope.$emit('ConfigReady'); }); }); @@ -57,15 +66,16 @@ angular.module('LoadConfigHelper', ['Utilities']) // load config.js $log.info('attempting to load config.js'); $http({ method:'GET', url: $basePath + 'config.js' }) - .success(function(data) { + .then(function(response) { $log.info('loaded config.js'); - $AnsibleConfig = eval(data); + $AnsibleConfig = eval(response.data); Store('AnsibleConfig', $AnsibleConfig); $rootScope.$emit('LoadConfig'); }) - .error(function(data, status) { - ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', - msg: 'Failed to load ' + $basePath + '/config.js. GET status: ' + status + .catch(function(response) { + response.data = 'Failed to load ' + $basePath + '/config.js'; + ProcessErrors($rootScope, response, response.status, null, { hdr: 'Error!', + msg: 'Failed to load ' + $basePath + '/config.js.' }); }); }; diff --git a/awx/ui/client/src/login/authenticationServices/authentication.service.js b/awx/ui/client/src/login/authenticationServices/authentication.service.js index 37426620a3..8ffced6d09 100644 --- a/awx/ui/client/src/login/authenticationServices/authentication.service.js +++ b/awx/ui/client/src/login/authenticationServices/authentication.service.js @@ -60,7 +60,8 @@ export default logout: function () { // the following puts our primary scope up for garbage collection, which // should prevent content flash from the prior user. - var scope = angular.element(document.getElementById('main-view')).scope(); + + var x, scope = angular.element(document.getElementById('main-view')).scope(); scope.$destroy(); //$rootScope.$destroy(); @@ -78,6 +79,9 @@ export default $cookieStore.remove('lastPath'); $rootScope.lastPath = '/home'; } + x = Store('sessionTime'); + x[$rootScope.current_user.id].loggedIn = false; + Store('sessionTime', x); $rootScope.lastUser = $cookieStore.get('current_user').id; $cookieStore.remove('token_expires'); @@ -94,7 +98,7 @@ export default $rootScope.token_expires = null; $rootScope.login_username = null; $rootScope.login_password = null; - $rootScope.sessionTimer.expireSession(); + $rootScope.sessionTimer.clearTimers(); }, getLicense: function () { @@ -112,6 +116,7 @@ export default var license = data.license_info; license.analytics_status = data.analytics_status; license.version = data.version; + license.ansible_version = data.ansible_version; license.tested = false; Store('license', license); $rootScope.features = Store('license').features; @@ -149,7 +154,6 @@ export default // store the response values in $rootScope so we can get to them later $rootScope.current_user = response.results[0]; $cookieStore.put('current_user', response.results[0]); //keep in session cookie in the event of browser refresh - $rootScope.$emit('OpenSocket'); }, restoreUserInfo: function () { diff --git a/awx/ui/client/src/login/authenticationServices/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo.service.js index 90fbef778a..d00baf2c14 100644 --- a/awx/ui/client/src/login/authenticationServices/pendo.service.js +++ b/awx/ui/client/src/login/authenticationServices/pendo.service.js @@ -12,7 +12,8 @@ export default Store, $log) { return { setPendoOptions: function (config) { - var options = { + var tower_version = config.version.split('-')[0], + options = { visitor: { id: null, role: null, @@ -23,7 +24,9 @@ export default planLevel: config.license_type, planPrice: config.instance_count, creationDate: config.license_date, - trial: config.trial + trial: config.trial, + tower_version: tower_version, + ansible_version: config.ansible_version } }; if(config.analytics_status === 'detailed'){ diff --git a/awx/ui/client/src/login/authenticationServices/timer.factory.js b/awx/ui/client/src/login/authenticationServices/timer.factory.js index 13ead3af02..7bbc3356c4 100644 --- a/awx/ui/client/src/login/authenticationServices/timer.factory.js +++ b/awx/ui/client/src/login/authenticationServices/timer.factory.js @@ -23,22 +23,21 @@ */ export default ['$rootScope', '$cookieStore', 'transitionTo', 'CreateDialog', 'Authorization', - 'Store', '$interval', + 'Store', '$interval', '$location', '$q', function ($rootScope, $cookieStore, transitionTo, CreateDialog, Authorization, - Store, $interval) { + Store, $interval, $location, $q) { return { sessionTime: null, timeout: null, getSessionTime: function () { - if(Store('sessionTime_'+$rootScope.current_user.id)){ - return Store('sessionTime_'+$rootScope.current_user.id); + if(Store('sessionTime')){ + return Store('sessionTime')[$rootScope.current_user.id].time; } else { - return 0; + return 0; } - }, isExpired: function (increase) { @@ -83,14 +82,24 @@ export default this.sessionTime = 0; this.clearTimers(); $cookieStore.put('sessionExpired', true); - transitionTo('signOut'); }, moveForward: function () { - var tm, t; + var tm, t, x, y; tm = ($AnsibleConfig.session_timeout) ? $AnsibleConfig.session_timeout : 1800; t = new Date().getTime() + (tm * 1000); - Store('sessionTime_'+$rootScope.current_user.id, t); + x = { + time: t, + loggedIn: true + }; + if(Store('sessionTime')){ + y = Store('sessionTime'); + } + else { + y = {}; + } + y[$rootScope.current_user.id] = x; + Store('sessionTime' , y); $rootScope.sessionExpired = false; $cookieStore.put('sessionExpired', false); this.startTimers(); @@ -148,6 +157,14 @@ export default $('#idle-modal').dialog('close'); } that.expireSession('idle'); + $location.url('/login'); + } + if(Store('sessionTime') && + Store('sessionTime')[$rootScope.current_user.id] && + Store('sessionTime')[$rootScope.current_user.id].loggedIn === false){ + that.expireSession(); + $location.url('/login'); + } }, 1000); @@ -156,11 +173,15 @@ export default clearTimers: function(){ $interval.cancel($rootScope.expireTimer); + delete $rootScope.expireTimer; }, init: function () { + var deferred = $q.defer(); this.moveForward(); - return this; + deferred.resolve(this); + return deferred.promise; + } }; } diff --git a/awx/ui/client/src/login/loginModal/loginModal.controller.js b/awx/ui/client/src/login/loginModal/loginModal.controller.js index 9741e2887c..9a2530b1f0 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.controller.js +++ b/awx/ui/client/src/login/loginModal/loginModal.controller.js @@ -110,17 +110,11 @@ export default ['$log', '$cookieStore', '$compile', '$window', '$rootScope', '$l setLoginFocus(); }); - - if ($AnsibleConfig.custom_logo) { - scope.customLogo = "custom_console_logo.png" - scope.customLogoPresent = true; - } else { - scope.customLogo = "login_modal_logo.png"; - scope.customLogoPresent = false; - } - - scope.customLoginInfo = $AnsibleConfig.custom_login_info; - scope.customLoginInfoPresent = (scope.customLoginInfo) ? true : false; + $rootScope.loginConfig.promise.then(function () { + scope.customLogo = ($AnsibleConfig.custom_logo) ? "custom_console_logo.png" : "tower_console_logo.png"; + scope.customLoginInfo = $AnsibleConfig.custom_login_info; + scope.customLoginInfoPresent = ($AnsibleConfig.customLoginInfo) ? true : false; + }); // Reset the login form //scope.loginForm.login_username.$setPristine(); @@ -171,9 +165,12 @@ export default ['$log', '$cookieStore', '$compile', '$window', '$rootScope', '$l Authorization.getUser() .success(function (data) { Authorization.setUserInfo(data); - $rootScope.sessionTimer = Timer.init(); - $rootScope.user_is_superuser = data.results[0].is_superuser; - scope.$emit('AuthorizationGetLicense'); + Timer.init().then(function(timer){ + $rootScope.sessionTimer = timer; + $rootScope.$emit('OpenSocket'); + $rootScope.user_is_superuser = data.results[0].is_superuser; + scope.$emit('AuthorizationGetLicense'); + }); }) .error(function (data, status) { Authorization.logout(); @@ -200,7 +197,7 @@ export default ['$log', '$cookieStore', '$compile', '$window', '$rootScope', '$l function (data) { var key; Wait('stop'); - if (data.data.non_field_errors && data.data.non_field_errors.length === 0) { + if (data && data.data && data.data.non_field_errors && data.data.non_field_errors.length === 0) { // show field specific errors returned by the API for (key in data.data) { scope[key + 'Error'] = data.data[key][0]; diff --git a/awx/ui/client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js b/awx/ui/client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js index 0110aac95b..4e3233c022 100644 --- a/awx/ui/client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js +++ b/awx/ui/client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js @@ -25,7 +25,7 @@ var options = [], error = ""; - function parseGoogle(option, key) { + function parseGoogle(option) { var newOption = {}; newOption.type = "google"; diff --git a/awx/ui/client/src/partials/about.html b/awx/ui/client/src/partials/about.html deleted file mode 100644 index fb1b4d1793..0000000000 --- a/awx/ui/client/src/partials/about.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - -
-
-
-

Tower Version

- -
-

Visit Ansible.com for more information!

-
-
- diff --git a/awx/ui/client/src/partials/job_detail.html b/awx/ui/client/src/partials/job_detail.html index 25164ff4bc..fd45b93891 100644 --- a/awx/ui/client/src/partials/job_detail.html +++ b/awx/ui/client/src/partials/job_detail.html @@ -33,8 +33,24 @@
- -
+ +
+
Previous Task Failed + + + + +
diff --git a/awx/ui/client/src/permissions/add/add.controller.js b/awx/ui/client/src/permissions/add/add.controller.js index 0dad88f9db..f90e9120a7 100644 --- a/awx/ui/client/src/permissions/add/add.controller.js +++ b/awx/ui/client/src/permissions/add/add.controller.js @@ -10,8 +10,8 @@ * @description This controller for permissions add */ export default - ['$scope', '$rootScope', '$compile', '$location', '$log', '$routeParams', 'permissionsForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'LoadBreadCrumbs', 'ClearScope', 'GetBasePath', 'ReturnToCaller', 'InventoryList', 'ProjectList', 'LookUpInit', 'CheckAccess', 'Wait', 'permissionsCategoryChange', 'permissionsChoices', 'permissionsLabel', - function($scope, $rootScope, $compile, $location, $log, $routeParams, permissionsForm, GenerateForm, Rest, Alert, ProcessErrors, LoadBreadCrumbs, ClearScope, GetBasePath, ReturnToCaller, InventoryList, ProjectList, LookUpInit, CheckAccess, Wait, permissionsCategoryChange, permissionsChoices, permissionsLabel) { + ['$scope', '$rootScope', '$compile', '$location', '$log', '$routeParams', 'permissionsForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'LoadBreadCrumbs', 'ClearScope', 'GetBasePath', 'ReturnToCaller', 'InventoryList', 'ProjectList', 'LookUpInit', 'CheckAccess', 'Wait', 'permissionsCategoryChange', 'fieldChoices', 'fieldLabels', + function($scope, $rootScope, $compile, $location, $log, $routeParams, permissionsForm, GenerateForm, Rest, Alert, ProcessErrors, LoadBreadCrumbs, ClearScope, GetBasePath, ReturnToCaller, InventoryList, ProjectList, LookUpInit, CheckAccess, Wait, permissionsCategoryChange, fieldChoices, fieldLabels) { ClearScope(); @@ -22,13 +22,14 @@ export default base = $location.path().replace(/^\//, '').split('/')[0], master = {}; - var permissionsChoice = permissionsChoices({ + var permissionsChoice = fieldChoices({ scope: $scope, - url: 'api/v1/' + base + '/' + id + '/permissions/' + url: 'api/v1/' + base + '/' + id + '/permissions/', + field: 'permission_type' }); permissionsChoice.then(function (choices) { - return permissionsLabel({ + return fieldLabels({ choices: choices }); }).then(function (choices) { diff --git a/awx/ui/client/src/permissions/edit/edit.controller.js b/awx/ui/client/src/permissions/edit/edit.controller.js index 1b1c6a552b..e209a309e2 100644 --- a/awx/ui/client/src/permissions/edit/edit.controller.js +++ b/awx/ui/client/src/permissions/edit/edit.controller.js @@ -10,8 +10,8 @@ * @description This controller for permissions edit */ export default - ['$scope', '$rootScope', '$compile', '$location', '$log', '$routeParams', 'permissionsForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'LoadBreadCrumbs', 'ReturnToCaller', 'ClearScope', 'Prompt', 'GetBasePath', 'InventoryList', 'ProjectList', 'LookUpInit', 'CheckAccess', 'Wait', 'permissionsCategoryChange', 'permissionsChoices', 'permissionsLabel', - function($scope, $rootScope, $compile, $location, $log, $routeParams, permissionsForm, GenerateForm, Rest, Alert, ProcessErrors, LoadBreadCrumbs, ReturnToCaller, ClearScope, Prompt, GetBasePath, InventoryList, ProjectList, LookUpInit, CheckAccess, Wait, permissionsCategoryChange, permissionsChoices, permissionsLabel) { + ['$scope', '$rootScope', '$compile', '$location', '$log', '$routeParams', 'permissionsForm', 'GenerateForm', 'Rest', 'Alert', 'ProcessErrors', 'LoadBreadCrumbs', 'ReturnToCaller', 'ClearScope', 'Prompt', 'GetBasePath', 'InventoryList', 'ProjectList', 'LookUpInit', 'CheckAccess', 'Wait', 'permissionsCategoryChange', 'fieldChoices', 'fieldLabels', + function($scope, $rootScope, $compile, $location, $log, $routeParams, permissionsForm, GenerateForm, Rest, Alert, ProcessErrors, LoadBreadCrumbs, ReturnToCaller, ClearScope, Prompt, GetBasePath, InventoryList, ProjectList, LookUpInit, CheckAccess, Wait, permissionsCategoryChange, fieldChoices, fieldLabels) { ClearScope(); @@ -25,13 +25,14 @@ export default $scope.permission_label = {}; - var permissionsChoice = permissionsChoices({ + var permissionsChoice = fieldChoices({ scope: $scope, - url: 'api/v1/' + base + '/' + base_id + '/permissions/' + url: 'api/v1/' + base + '/' + base_id + '/permissions/', + field: 'permission_type' }); permissionsChoice.then(function (choices) { - return permissionsLabel({ + return fieldLabels({ choices: choices }); }).then(function (choices) { diff --git a/awx/ui/client/src/permissions/list/list.controller.js b/awx/ui/client/src/permissions/list/list.controller.js index 4e9ea4c716..341150b71b 100644 --- a/awx/ui/client/src/permissions/list/list.controller.js +++ b/awx/ui/client/src/permissions/list/list.controller.js @@ -12,8 +12,8 @@ export default - ['$scope', '$rootScope', '$location', '$log', '$routeParams', 'Rest', 'Alert', 'permissionsList', 'generateList', 'LoadBreadCrumbs', 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', 'GetBasePath', 'CheckAccess', 'Wait', 'permissionsChoices', 'permissionsLabel', 'permissionsSearchSelect', - function ($scope, $rootScope, $location, $log, $routeParams, Rest, Alert, permissionsList, GenerateList, LoadBreadCrumbs, Prompt, SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, CheckAccess, Wait, permissionsChoices, permissionsLabel, permissionsSearchSelect) { + ['$scope', '$rootScope', '$location', '$log', '$routeParams', 'Rest', 'Alert', 'permissionsList', 'generateList', 'LoadBreadCrumbs', 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', 'GetBasePath', 'CheckAccess', 'Wait', 'fieldChoices', 'fieldLabels', 'permissionsSearchSelect', + function ($scope, $rootScope, $location, $log, $routeParams, Rest, Alert, permissionsList, GenerateList, LoadBreadCrumbs, Prompt, SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, CheckAccess, Wait, fieldChoices, fieldLabels, permissionsSearchSelect) { ClearScope(); @@ -27,16 +27,17 @@ export default $scope.permission_search_select = []; // return a promise from the options request with the permission type choices (including adhoc) as a param - var permissionsChoice = permissionsChoices({ + var permissionsChoice = fieldChoices({ scope: $scope, - url: 'api/v1/' + base + '/' + base_id + '/permissions/' + url: 'api/v1/' + base + '/' + base_id + '/permissions/', + field: 'permission_type' }); // manipulate the choices from the options request to be set on // scope and be usable by the list form permissionsChoice.then(function (choices) { choices = - permissionsLabel({ + fieldLabels({ choices: choices }); _.map(choices, function(n, key) { diff --git a/awx/ui/client/src/permissions/main.js b/awx/ui/client/src/permissions/main.js index fef273cfa6..0bb913370d 100644 --- a/awx/ui/client/src/permissions/main.js +++ b/awx/ui/client/src/permissions/main.js @@ -12,8 +12,6 @@ import list from './shared/permissions.list'; import form from './shared/permissions.form'; import permissionsCategoryChange from './shared/category-change.factory'; -import permissionsChoices from './shared/get-choices.factory'; -import permissionsLabel from './shared/get-labels.factory'; import permissionsSearchSelect from './shared/get-search-select.factory'; export default @@ -25,6 +23,4 @@ export default .factory('permissionsList', list) .factory('permissionsForm', form) .factory('permissionsCategoryChange', permissionsCategoryChange) - .factory('permissionsChoices', permissionsChoices) - .factory('permissionsLabel', permissionsLabel) .factory('permissionsSearchSelect', permissionsSearchSelect); diff --git a/awx/ui/client/src/permissions/shared/get-choices.factory.js b/awx/ui/client/src/rest/get-choices.factory.js similarity index 66% rename from awx/ui/client/src/permissions/shared/get-choices.factory.js rename to awx/ui/client/src/rest/get-choices.factory.js index 8ad55907a5..33537d5ab4 100644 --- a/awx/ui/client/src/permissions/shared/get-choices.factory.js +++ b/awx/ui/client/src/rest/get-choices.factory.js @@ -16,24 +16,28 @@ ['Rest', 'ProcessErrors', function(Rest, ProcessErrors) { return function (params) { var scope = params.scope, - url = params.url; + url = params.url, + field = params.field; // Auto populate the field if there is only one result Rest.setUrl(url); return Rest.options() .then(function (data) { data = data.data; - var choices = data.actions.GET.permission_type.choices; + var choices = data.actions.GET[field].choices; - // manually add the adhoc label to the choices object - choices.push(["adhoc", - data.actions.GET.run_ad_hoc_commands.help_text]); + // manually add the adhoc label to the choices object if + // the permission_type field + if (field === "permission_type") { + choices.push(["adhoc", + data.actions.GET.run_ad_hoc_commands.help_text]); + } return choices; }) .catch(function (data, status) { ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to get permission type labels. Options requrest returned status: ' + status }); + msg: 'Failed to get ' + field + ' labels. Options requrest returned status: ' + status }); }); }; }]; diff --git a/awx/ui/client/src/permissions/shared/get-labels.factory.js b/awx/ui/client/src/rest/get-labels.factory.js similarity index 100% rename from awx/ui/client/src/permissions/shared/get-labels.factory.js rename to awx/ui/client/src/rest/get-labels.factory.js diff --git a/awx/ui/client/src/rest/interceptors.service.js b/awx/ui/client/src/rest/interceptors.service.js index 5ef77a40f4..2ace57abee 100644 --- a/awx/ui/client/src/rest/interceptors.service.js +++ b/awx/ui/client/src/rest/interceptors.service.js @@ -11,8 +11,8 @@ *************************************************/ export default - [ '$rootScope', '$q', - function ($rootScope, $q) { + [ '$rootScope', '$q', '$injector', + function ($rootScope, $q, $injector) { return { response: function(config) { if(config.headers('auth-token-timeout') !== null){ @@ -21,8 +21,10 @@ return config; }, responseError: function(rejection){ - if( rejection.data && !_.isEmpty(rejection.data.detail) && rejection.data.detail === "Maximum per-user sessions reached"){ + if(rejection && rejection.data && rejection.data.detail && rejection.data.detail === "Maximum per-user sessions reached"){ $rootScope.sessionTimer.expireSession('session_limit'); + var location = $injector.get('$location'); + location.url('/login'); return $q.reject(rejection); } return $q.reject(rejection); diff --git a/awx/ui/client/src/rest/main.js b/awx/ui/client/src/rest/main.js index 335106af5d..5466355a12 100644 --- a/awx/ui/client/src/rest/main.js +++ b/awx/ui/client/src/rest/main.js @@ -6,6 +6,8 @@ import restServicesFactory from './restServices.factory'; import interceptors from './interceptors.service'; +import fieldChoices from './get-choices.factory'; +import fieldLabels from './get-labels.factory'; export default angular.module('RestServices', []) @@ -13,4 +15,6 @@ export default $httpProvider.interceptors.push('RestInterceptor'); }]) .factory('Rest', restServicesFactory) - .service('RestInterceptor', interceptors); + .service('RestInterceptor', interceptors) + .factory('fieldChoices', fieldChoices) + .factory('fieldLabels', fieldLabels); diff --git a/awx/ui/client/src/setup-menu/setup-menu.partial.html b/awx/ui/client/src/setup-menu/setup-menu.partial.html index a07d50a3e8..630ce5f570 100644 --- a/awx/ui/client/src/setup-menu/setup-menu.partial.html +++ b/awx/ui/client/src/setup-menu/setup-menu.partial.html @@ -53,8 +53,8 @@

- - + +
diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index de9bbf67b2..5cc39aa8b3 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -252,15 +252,20 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) if ((!fieldErrors) && defaultMsg) { Alert(defaultMsg.hdr, defaultMsg.msg); } - } else if (typeof data === 'object' && Object.keys(data).length > 0) { - keys = Object.keys(data); - if (Array.isArray(data[keys[0]])) { - msg = data[keys[0]][0]; + } else if (typeof data === 'object' && data !== null){ + if(Object.keys(data).length > 0) { + keys = Object.keys(data); + if (Array.isArray(data[keys[0]])) { + msg = data[keys[0]][0]; + } + else { + msg = data[keys[0]]; + } + Alert(defaultMsg.hdr, msg); } else { - msg = data[keys[0]]; + Alert(defaultMsg.hdr, defaultMsg.msg); } - Alert(defaultMsg.hdr, msg); } else { Alert(defaultMsg.hdr, defaultMsg.msg); } diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index f71bcc496c..4f21bb3f55 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -275,7 +275,7 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) if (/^\-?\d*$/.test(viewValue)) { // it is valid ctrl.$setValidity('integer', true); - if ( viewValue === '-' || viewValue === '-0' || viewValue === '' || viewValue === null) { + if ( viewValue === '-' || viewValue === '-0' || viewValue === null) { ctrl.$setValidity('integer', false); return viewValue; } diff --git a/awx/ui/client/src/widgets/Stream.js b/awx/ui/client/src/widgets/Stream.js index 2e5a788b9f..5cbd299edf 100644 --- a/awx/ui/client/src/widgets/Stream.js +++ b/awx/ui/client/src/widgets/Stream.js @@ -221,15 +221,68 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti if (obj2_obj && obj2_obj.name && !/^_delete/.test(obj2_obj.name)) { obj2_obj.base = obj2; obj2_obj.name = $filter('sanitize')(obj2_obj.name); - descr += obj2 + " " + obj2_obj.name + '' + ((activity.operation === 'disassociate') ? ' from ' : ' to '); - descr_nolink += obj2 + ' ' + obj2_obj.name + ((activity.operation === 'disassociate') ? ' from ' : ' to '); + descr += obj2 + + " " + + obj2_obj.name + ''; + if (activity.object_association === 'admins') { + if (activity.operation === 'disassociate') { + descr += ' from being an admin of '; + } else { + descr += ' as an admin of '; + } + } else { + if (activity.operation === 'disassociate') { + descr += ' from '; + } else { + descr += ' to '; + } + } + descr_nolink += obj2 + ' ' + obj2_obj.name; + if (activity.object_association === 'admins') { + if (activity.operation === 'disassociate') { + descr_nolink += ' from being an admin of '; + } else { + descr_nolink += ' as an admin of '; + } + } else { + if (activity.operation === 'disassociate') { + descr_nolink += ' from '; + } else { + descr_nolink += ' to '; + } + } } else if (obj2) { name = ''; if (obj2_obj && obj2_obj.name) { name = ' ' + stripDeleted(obj2_obj.name); } - descr += obj2 + name + ((activity.operation === 'disassociate') ? ' from ' : ' to '); - descr_nolink += obj2 + name + ((activity.operation === 'disassociate') ? ' from ' : ' to '); + if (activity.object_association === 'admins') { + if (activity.operation === 'disassociate') { + descr += ' from being an admin of '; + } else { + descr += ' as an admin of '; + } + } else { + if (activity.operation === 'disassociate') { + descr += ' from '; + } else { + descr += ' to '; + } + } + descr_nolink += obj2 + ' ' + obj2_obj.name; + if (activity.object_association === 'admins') { + if (activity.operation === 'disassociate') { + descr_nolink += ' from being an admin of '; + } else { + descr_nolink += ' as an admin of '; + } + } else { + if (activity.operation === 'disassociate') { + descr_nolink += ' from '; + } else { + descr_nolink += ' to '; + } + } } if (obj1_obj && obj1_obj.name && !/^\_delete/.test(obj1_obj.name)) { obj1_obj.base = obj1; diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index 242714fc7e..ec9c4e8272 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -31,7 +31,8 @@ window.pendo_options = { // This is required to be able to load data client side - usePendoAgentAPI: true + usePendoAgentAPI: true, + disableGuides: true }; @@ -55,7 +56,7 @@ - +