diff --git a/app_setup/templates/local_settings.py.j2 b/app_setup/templates/local_settings.py.j2 index 57235a0808..0cb4c96a80 100644 --- a/app_setup/templates/local_settings.py.j2 +++ b/app_setup/templates/local_settings.py.j2 @@ -104,6 +104,10 @@ LOGGING['handlers']['syslog'] = { # 'formatter': 'simple', #} +# Enable the following lines to turn on lots of permissions-related logging. +#LOGGING['loggers']['awx.main.access']['propagate'] = True +#LOGGING['loggers']['awx.main.permissions']['propagate'] = True + # Define additional environment variables to be passed to subprocess started by # the celery task. #AWX_TASK_ENV['FOO'] = 'BAR' diff --git a/awx/main/access.py b/awx/main/access.py index 057694351c..c0b103ffd7 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -13,6 +13,7 @@ from django.contrib.auth.models import User from rest_framework.exceptions import PermissionDenied # AWX +from awx.main.utils import * from awx.main.models import * from awx.main.licenses import LicenseReader @@ -140,15 +141,25 @@ class BaseAccess(object): return self.can_change(obj, None) class UserAccess(BaseAccess): + ''' + I can see user records when: + - I'm a superuser. + - I'm that user. + - I'm their org admin. + - I'm on a team with that user. + I can change some fields for a user (mainly password) when I am that user. + I can change all fields for a user (admin access) or delete when: + - I'm a superuser. + - I'm their org admin. + ''' model = User def get_queryset(self): - # I can see user records when I'm a superuser, I'm that user, I'm - # their org admin, or I'm on a team with that user. + qs = self.model.objects.filter(is_active=True).distinct() if self.user.is_superuser: - return self.model.objects.all() - return self.model.objects.filter(is_active=True).filter( + return qs + return qs.filter( Q(pk=self.user.pk) | Q(organizations__in=self.user.admin_of_organizations.all()) | Q(teams__in=self.user.teams.all()) @@ -167,31 +178,40 @@ class UserAccess(BaseAccess): def can_change(self, obj, data): # A user can be changed if they are themselves, or by org admins or - # superusers. + # superusers. Change permission implies changing only certain fields + # that a user should be able to edit for themselves. + return bool(self.user == obj or self.can_admin(obj, data)) + + def can_admin(self, obj, data): + # Admin implies changing all user fields. if self.user.is_superuser: return True - if self.user == obj: - return 'partial' return bool(obj.organizations.filter(admins__in=[self.user]).count()) def can_delete(self, obj): if obj == self.user: # cannot delete yourself return False - super_users = User.objects.filter(is_superuser=True) + super_users = User.objects.filter(is_active=True, is_superuser=True) if obj.is_superuser and super_users.count() == 1: - # cannot delete the last superuser + # cannot delete the last active superuser return False return bool(self.user.is_superuser or obj.organizations.filter(admins__in=[self.user]).count()) class OrganizationAccess(BaseAccess): + ''' + I can see organizations when: + - I am a superuser. + - I am an admin or user in that organization. + I can change or delete organizations when: + - I am a superuser. + - I'm an admin of that organization. + ''' model = Organization def get_queryset(self): - # I can see organizations when I am a superuser, or I am an admin or - # user in that organization. qs = self.model.objects.distinct() if self.user.is_superuser: return qs @@ -203,146 +223,103 @@ class OrganizationAccess(BaseAccess): def can_change(self, obj, data): return bool(self.user.is_superuser or - obj.created_by == self.user or self.user in obj.admins.all()) def can_delete(self, obj): return self.can_change(obj, None) class InventoryAccess(BaseAccess): + ''' + I can see inventory when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have read, write or admin permissions on it. + I can change inventory when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have write or admin permissions on it. + I can delete inventory when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have admin permissions on it. + ''' model = Inventory - def get_queryset(self): - # I can see inventory when I'm a superuser, an org admin of the - # inventory, or I have permissions on it. - base = Inventory.objects.distinct() + def get_queryset(self, allowed=None): + allowed = allowed or PERMISSION_TYPES_ALLOWING_INVENTORY_READ + qs = Inventory.objects.filter(active=True).distinct() if self.user.is_superuser: - return base.all() - admin_of = base.filter(organization__admins__in = [ self.user ]).distinct() - has_user_perms = base.filter( - permissions__user__in = [ self.user ], - permissions__permission_type__in = PERMISSION_TYPES_ALLOWING_INVENTORY_READ, + return qs + admin_of = qs.filter(organization__admins__in=[self.user]).distinct() + has_user_perms = qs.filter( + permissions__user__in=[self.user], + permissions__permission_type__in=allowed, ).distinct() - has_team_perms = base.filter( - permissions__team__in = self.user.teams.all(), - permissions__permission_type__in = PERMISSION_TYPES_ALLOWING_INVENTORY_READ, + has_team_perms = qs.filter( + permissions__team__users__in=[self.user], + permissions__permission_type__in=allowed, ).distinct() return admin_of | has_user_perms | has_team_perms - def _has_permission_types(self, obj, allowed): - if self.user.is_superuser: - return True - by_org_admin = obj.organization.admins.filter(pk = self.user.pk).count() - by_team_permission = obj.permissions.filter( - team__in = self.user.teams.all(), - permission_type__in = allowed - ).count() - by_user_permission = obj.permissions.filter( - user = self.user, - permission_type__in = allowed - ).count() - - result = (by_org_admin + by_team_permission + by_user_permission) - return result > 0 - - def _has_any_inventory_permission_types(self, allowed): - ''' - rather than checking for a permission on a specific inventory, return whether we have - permissions on any inventory. This is primarily used to decide if the user can create - host or group objects - ''' - - if self.user.is_superuser: - return True - by_org_admin = self.user.organizations.filter( - admins__in = [ self.user ] - ).count() - by_team_permission = Permission.objects.filter( - team__in = self.user.teams.all(), - permission_type__in = allowed - ).count() - by_user_permission = self.user.permissions.filter( - permission_type__in = allowed - ).count() - - result = (by_org_admin + by_team_permission + by_user_permission) - return result > 0 + def has_permission_types(self, obj, allowed): + return bool(obj and self.get_queryset(allowed).filter(pk=obj.pk).count()) def can_read(self, obj): - return self._has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_READ) + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_READ) def can_add(self, data): - if not 'organization' in data: - return True + # If no data is specified, just checking for generic add permission? + if not data: + return bool(self.user.is_superuser or self.user.admin_of_organizations.count()) + # Otherwise, verify that the user has access to change the parent + # organization of this inventory. if self.user.is_superuser: return True - if not self.user.is_superuser: - org = Organization.objects.get(pk=data['organization']) - if self.user in org.admins.all(): + else: + org = get_object_or_400(Organization, pk=data.get('organization', None)) + if self.user.can_access(Organization, 'change', org, None): return True return False def can_change(self, obj, data): - return self._has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) + # Verify that the user has access to the given organization. + if data and 'organization' in data and not self.can_add(data): + return False + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) def can_admin(self, obj, data): - return self._has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN) + # Verify that the user has access to the given organization. + if data and 'organization' in data and not self.can_add(data): + return False + return self.has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN) def can_delete(self, obj): - return self._has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_ADMIN) - - def can_attach(self, obj, sub_obj, relationship, data, - skip_sub_obj_read_check=False): - ''' whether you can add sub_obj to obj using the relationship type in a subobject view ''' - #if not sub_obj.can_user_read(user, sub_obj): - if sub_obj and not skip_sub_obj_read_check: - if not self.user.can_access(type(sub_obj), 'read', sub_obj): - return False - return self._has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) - - def can_unattach(self, obj, sub_obj, relationship): - return self._has_permission_types(obj, PERMISSION_TYPES_ALLOWING_INVENTORY_WRITE) + return self.can_admin(obj, None) class HostAccess(BaseAccess): + ''' + I can see hosts whenever I can see their inventory. + I can change or delete hosts whenver I can change their inventory. + ''' model = Host def get_queryset(self): - ''' - I can see hosts when: - I'm a superuser, - or an organization admin of an inventory they are in - or when I have allowing read permissions via a user or team on an inventory they are in - ''' - base = self.model.objects - if self.user.is_superuser: - return base.all() - admin_of = base.filter(inventory__organization__admins__in = [ self.user ]).distinct() - has_user_perms = base.filter( - inventory__permissions__user__in = [ self.user ], - inventory__permissions__permission_type__in = PERMISSION_TYPES_ALLOWING_INVENTORY_READ, - ).distinct() - has_team_perms = base.filter( - inventory__permissions__team__in = self.user.teams.all(), - inventory__permissions__permission_type__in = PERMISSION_TYPES_ALLOWING_INVENTORY_READ, - ).distinct() - return admin_of | has_user_perms | has_team_perms + qs = self.model.objects.filter(active=True).distinct() + inventories_qs = self.user.get_queryset(Inventory) + return qs.filter(inventory__in=inventories_qs) def can_read(self, obj): - return self.user.can_access(Inventory, 'read', obj.inventory) + return obj and self.user.can_access(Inventory, 'read', obj.inventory) def can_add(self, data): - - - if not 'inventory' in data: + if not data or not 'inventory' in data: return False - inventory = Inventory.objects.get(pk=data['inventory']) - # Checks for admin or change permission on inventory. - permissions_ok = self.user.can_access(Inventory, 'change', inventory, None) - if not permissions_ok: + inventory = get_object_or_400(Inventory, pk=data.get('inventory', None)) + if not self.user.can_access(Inventory, 'change', inventory, None): return False # Check to see if we have enough licenses @@ -361,57 +338,46 @@ class HostAccess(BaseAccess): raise PermissionDenied("license range of %s instances has been exceed" % instances) def can_change(self, obj, data): + # Prevent moving a host to a different inventory. + if obj and data and obj.inventory.pk != data.get('inventory', None): + raise PermissionDenied('Unable to change inventory on a host') # Checks for admin or change permission on inventory, controls whether # the user can edit variable data. - return self.user.can_access(Inventory, 'change', obj.inventory, None) + return obj and self.user.can_access(Inventory, 'change', obj.inventory, None) class GroupAccess(BaseAccess): + ''' + I can see groups whenever I can see their inventory. + I can change or delete groups whenever I can change their inventory. + ''' model = Group def get_queryset(self): - ''' - I can see groups when: - I'm a superuser, - or an organization admin of an inventory they are in - or when I have allowing read permissions via a user or team on an inventory they are in - ''' - base = Group.objects - if self.user.is_superuser: - return base.distinct() - admin_of = base.filter(inventory__organization__admins__in = [ self.user ]).distinct() - has_user_perms = base.filter( - inventory__permissions__user__in = [ self.user ], - inventory__permissions__permission_type__in = PERMISSION_TYPES_ALLOWING_INVENTORY_READ, - ).distinct() - has_team_perms = base.filter( - inventory__permissions__team__in = self.user.teams.all(), - inventory__permissions__permission_type__in = PERMISSION_TYPES_ALLOWING_INVENTORY_READ, - ).distinct() - return admin_of | has_user_perms | has_team_perms + qs = self.model.objects.filter(active=True).distinct() + inventories_qs = self.user.get_queryset(Inventory) + return qs.filter(inventory__in=inventories_qs) def can_read(self, obj): - return self.user.can_access(Inventory, 'read', obj.inventory) + return obj and self.user.can_access(Inventory, 'read', obj.inventory) def can_add(self, data): - if not 'inventory' in data: + if not data or not 'inventory' in data: return False - inventory = Inventory.objects.get(pk=data['inventory']) # Checks for admin or change permission on inventory. + inventory = get_object_or_400(Inventory, pk=data.get('inventory', None)) return self.user.can_access(Inventory, 'change', inventory, None) def can_change(self, obj, data): # Checks for admin or change permission on inventory, controls whether # the user can attach subgroups or edit variable data. - return self.user.can_access(Inventory, 'change', obj.inventory, None) + return obj and self.user.can_access(Inventory, 'change', obj.inventory, None) def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): - if not self.can_change(obj, None): + if not super(GroupAccess, self).can_attach(obj, sub_obj, relationship, + data, skip_sub_obj_read_check): return False - if sub_obj and not skip_sub_obj_read_check: - if not self.user.can_access(type(sub_obj), 'read', sub_obj): - return False # Prevent group from being assigned as its own (grand)child. if type(obj) == type(sub_obj): @@ -425,22 +391,33 @@ class GroupAccess(BaseAccess): return True class CredentialAccess(BaseAccess): + ''' + I can see credentials when: + - I'm a superuser. + - It's a user credential and it's my credential. + - It's a user credential and I'm an admin of an organization where that + user is a member of admin of the organization. + - It's a team credential and I'm an admin of the team's organization. + - It's a team credential and I'm a member of the team. + ''' model = Credential def get_queryset(self): - # I can see credentials when: - # - It's a user credential and it's my credential. - # - It's a user credential and I'm an admin of an organization - # - - # FIXME - qs = self.model.objects.distinct() + qs = self.model.objects.filter(active=True).distinct() if self.user.is_superuser: return qs - return qs.filter(Q(user=self.user)) + orgs_as_admin = self.user.admin_of_organizations.all() + return qs.filter( + Q(user=self.user) | + Q(user__organizations__in=orgs_as_admin) | + Q(user__admin_of_organizations__in=orgs_as_admin) | + Q(team__organization__in=orgs_as_admin) | + Q(team__users__in=[self.user]) + ) def can_read(self, obj): - return self.can_change(obj, None) + return obj and self.can_change(obj, None) def can_add(self, data): if self.user.is_superuser: @@ -476,7 +453,17 @@ class TeamAccess(BaseAccess): model = Team def get_queryset(self): - return self.model.objects.distinct() # FIXME + # I can see a team when: + # - I'm a superuser. + # - I'm an admin of the team's organization. + # - I'm a member of that team. + qs = self.model.objects.filter(active=True).distinct() + if self.user.is_superuser: + return qs + return qs.filter( + Q(organization__admins__in=[self.user]) | + Q(users__in=[self.user]) + ) def can_add(self, data): if self.user.is_superuser: @@ -587,13 +574,16 @@ class JobTemplateAccess(BaseAccess): project's orgs, or if I'm in a team on the project. This does not mean I would be able to launch a job from the template or edit the template. ''' + # FIXME: Don't think this is quite right... qs = self.model.objects.all() if self.user.is_superuser: return qs.all() - return qs.filter(active=True).filter( + qs = qs.filter(active=True).filter( Q(project__organizations__admins__in=[self.user]) | Q(project__teams__users__in=[self.user]) ).distinct() + #print qs.values_list('name', flat=True) + return qs def can_read(self, obj): # you can only see the job templates that you have permission to launch. diff --git a/awx/main/base_views.py b/awx/main/base_views.py index 93bee79bab..58b08947b4 100644 --- a/awx/main/base_views.py +++ b/awx/main/base_views.py @@ -31,9 +31,23 @@ class ListAPIView(generics.ListAPIView): def get_queryset(self): return self.request.user.get_queryset(self.model) + def get_description_vars(self): + return { + 'model_verbose_name': unicode(self.model._meta.verbose_name), + 'model_verbose_name_plural': unicode(self.model._meta.verbose_name_plural), + } + + def get_description(self, html=False): + s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s.' + return s % self.get_description_vars() + class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView): # Base class for a list view that allows creating new objects. - pass + + def get_description(self, html=False): + s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s.' + s2 = 'Use a POST request with required %(model_verbose_name)s fields to create a new %(model_verbose_name)s.' + return '\n\n'.join([s, s2]) % self.get_description_vars() class SubListAPIView(ListAPIView): # Base class for a read-only sublist view. @@ -47,6 +61,18 @@ class SubListAPIView(ListAPIView): # to view sublist): # parent_access = 'admin' + def get_description_vars(self): + d = super(SubListAPIView, self).get_description_vars() + d.update({ + 'parent_model_verbose_name': unicode(self.parent_model._meta.verbose_name), + 'parent_model_verbose_name_plural': unicode(self.parent_model._meta.verbose_name_plural), + }) + return d + + def get_description(self, html=False): + s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s associated with the selected %(parent_model_verbose_name)s.' + return s % self.get_description_vars() + def get_parent_object(self): parent_filter = { self.lookup_field: self.kwargs.get(self.lookup_field, None), @@ -70,13 +96,23 @@ class SubListAPIView(ListAPIView): sublist_qs = getattr(parent, self.relationship).distinct() return qs & sublist_qs -class SubListCreateAPIView(SubListAPIView, generics.ListCreateAPIView): +class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): # Base class for a sublist view that allows for creating subobjects and # attaching/detaching them from the parent. # In addition to SubListAPIView properties, subclasses may define: - # inject_primary_key_on_post_as = 'field_on_model_referring_to_parent' - # severable = True/False + # parent_key = 'field_on_model_referring_to_parent' + + def get_description(self, html=False): + s = 'Use a GET request to retrieve a list of %(model_verbose_name_plural)s associated with the selected %(parent_model_verbose_name)s.' + s2 = 'Use a POST request with required %(model_verbose_name)s fields to create a new %(model_verbose_name)s.' + if getattr(self, 'parent_key', None): + s3 = 'Use a POST request with an `id` field and `disassociate` set to delete the associated %(model_verbose_name)s.' + s4 = '' + else: + s3 = 'Use a POST request with only an `id` field to associate an existing %(model_verbose_name)s with this %(parent_model_verbose_name)s.' + s4 = 'Use a POST request with an `id` field and `disassociate` set to remove the %(model_verbose_name)s from this %(parent_model_verbose_name)s without deleting the %(model_verbose_name)s.' + return '\n\n'.join(filter(None, [s, s2, s3, s4])) % self.get_description_vars() def create(self, request, *args, **kwargs): # If the object ID was not specified, it probably doesn't exist in the @@ -84,13 +120,6 @@ class SubListCreateAPIView(SubListAPIView, generics.ListCreateAPIView): # inject it's primary key into the object because we are posting to a # subcollection. Use all the normal access control mechanisms. - inject_primary_key = getattr(self, 'inject_primary_key_on_post_as', None) - - if inject_primary_key is None: - # view didn't specify a way to get the pk from the URL, so not even trying - return Response(status=status.HTTP_400_BAD_REQUEST, - data=dict(msg='object cannot be created')) - # Make a copy of the data provided (since it's readonly) in order to # inject additional data. if hasattr(request.DATA, 'dict'): @@ -98,8 +127,10 @@ class SubListCreateAPIView(SubListAPIView, generics.ListCreateAPIView): else: data = request.DATA - # add the key to the post data using the pk from the URL - data[inject_primary_key] = kwargs['pk'] + # add the parent key to the post data using the pk from the URL + parent_key = getattr(self, 'parent_key', None) + if parent_key: + data[parent_key] = self.kwargs['pk'] # attempt to deserialize the object serializer = self.serializer_class(data=data) @@ -160,7 +191,7 @@ class SubListCreateAPIView(SubListAPIView, generics.ListCreateAPIView): return Response(data, status=status.HTTP_400_BAD_REQUEST) parent = self.get_parent_object() - severable = getattr(self, 'severable', True) + parent_key = getattr(self, 'parent_key', None) relationship = getattr(parent, self.relationship) try: @@ -172,11 +203,12 @@ class SubListCreateAPIView(SubListAPIView, generics.ListCreateAPIView): if not request.user.can_access(self.parent_model, 'unattach', parent, sub, self.relationship): raise PermissionDenied() - if severable: - relationship.remove(sub) - else: - # resource is just a ForeignKey, can't remove it from the set, just set it inactive + if parent_key: + # sub object has a ForeignKey to the parent, so we can't remove it + # from the set, only mark it as inactive. sub.mark_inactive() + else: + relationship.remove(sub) return Response(status=status.HTTP_204_NO_CONTENT) @@ -186,7 +218,6 @@ class SubListCreateAPIView(SubListAPIView, generics.ListCreateAPIView): else: return self.attach(request, *args, **kwargs) - class RetrieveAPIView(generics.RetrieveAPIView): pass diff --git a/awx/main/filters.py b/awx/main/filters.py index 6f7eb78d1f..f44a6ec704 100644 --- a/awx/main/filters.py +++ b/awx/main/filters.py @@ -1,16 +1,17 @@ # Copyright (c) 2013 AnsibleWorks, Inc. # All Rights Reserved. +# Django +from django.core.exceptions import FieldError + # Django REST Framework +from rest_framework.exceptions import ParseError from rest_framework.filters import BaseFilterBackend class DefaultFilterBackend(BaseFilterBackend): def filter_queryset(self, request, queryset, view): - terms = {} - order_by = None - # Filtering by is_active/active that was previously in BaseList. qs = queryset for field in queryset.model._meta.fields: @@ -19,25 +20,34 @@ class DefaultFilterBackend(BaseFilterBackend): elif field.name == 'active': qs = qs.filter(active=True) - for key, value in request.QUERY_PARAMS.items(): + # Apply filters and ordering specified via QUERY_PARAMS. + try: - if key in [ 'page', 'page_size', 'format' ]: - continue + filters = {} + order_by = None - if key in ('order', 'order_by'): - order_by = value - continue + for key, value in request.QUERY_PARAMS.items(): - key2 = key - if key2.endswith("__int"): - key2 = key.replace("__int","") - value = int(value) + if key in ('page', 'page_size', 'format'): + continue - terms[key2] = value + if key in ('order', 'order_by'): + order_by = value + continue - qs = qs.filter(**terms) + if key.endswith('__int'): + key = key.replace('__int', '') + value = int(value) - if order_by: - qs = qs.order_by(order_by) + filters[key] = value + + qs = qs.filter(**filters) + + if order_by: + qs = qs.order_by(order_by) + + except (FieldError, ValueError), e: + # Handle invalid field names or values and return a 400. + raise ParseError(*e.args) return qs diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index e9f50f9a20..3f8d7634a6 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -898,7 +898,7 @@ class JobHostSummary(models.Model): class Meta: unique_together = [('job', 'host')] - verbose_name_plural = _('Job Host Summaries') + verbose_name_plural = _('job host summaries') ordering = ('-pk',) job = models.ForeignKey( diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index c4b648d3e5..6c290507c5 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -124,13 +124,15 @@ class BaseTestMixin(object): self.normal_password = 'normal' self.other_username = 'other' self.other_password = 'other' + self.nobody_username = 'nobody' + self.nobody_password = 'nobody' self.super_django_user = self.make_user(self.super_username, self.super_password, super_user=True) if not just_super_user: - self.normal_django_user = self.make_user(self.normal_username, self.normal_password, super_user=False) self.other_django_user = self.make_user(self.other_username, self.other_password, super_user=False) + self.nobody_django_user = self.make_user(self.nobody_username, self.nobody_password, super_user=False) def get_super_credentials(self): return (self.super_username, self.super_password) @@ -141,6 +143,10 @@ class BaseTestMixin(object): def get_other_credentials(self): return (self.other_username, self.other_password) + def get_nobody_credentials(self): + # here is a user without any permissions... + return (self.nobody_username, self.nobody_password) + def get_invalid_credentials(self): return ('random', 'combination') @@ -239,6 +245,39 @@ class BaseTestMixin(object): data = self.get(collection_url, expect=200, auth=auth) return [item['url'] for item in data['results']] + def check_invalid_auth(self, url, data=None, methods=None): + ''' + Check various methods of accessing the given URL with invalid + authentication credentials. + ''' + data = data or {} + methods = methods or ('options', 'head', 'get') + for auth in [(None,), ('invalid', 'password')]: + with self.current_user(*auth): + for method in methods: + f = getattr(self, method) + if method in ('post', 'put', 'patch'): + f(url, data, expect=401) + else: + f(url, expect=401) + + def check_get_list(self, url, user, qs, fields=None, expect=200): + ''' + Check that the given list view URL returns results for the given user + that match the given queryset. + ''' + with self.current_user(user): + self.options(url, expect=expect) + self.head(url, expect=expect) + response = self.get(url, expect=expect) + if expect != 200: + return + self.check_pagination_and_size(response, qs.count()) + self.check_list_ids(response, qs) + if fields: + for obj in response['results']: + self.assertTrue(set(obj.keys()) <= set(fields)) + class BaseTest(BaseTestMixin, django.test.TestCase): ''' Base class for unit tests. diff --git a/awx/main/tests/inventory.py b/awx/main/tests/inventory.py index a94b0b5cea..982d73faae 100644 --- a/awx/main/tests/inventory.py +++ b/awx/main/tests/inventory.py @@ -33,15 +33,137 @@ class InventoryTest(BaseTest): permission_type = 'read' ) - # and make one more user that won't be a part of any org, just for negative-access testing + def test_get_inventory_list(self): + url = reverse('main:inventory_list') + qs = Inventory.objects.filter(active=True).distinct() - self.nobody_django_user = User.objects.create(username='nobody') - self.nobody_django_user.set_password('nobody') - self.nobody_django_user.save() + # Check list view with invalid authentication. + self.check_invalid_auth(url) - def get_nobody_credentials(self): - # here is a user without any permissions... - return ('nobody', 'nobody') + # a super user can list all inventories + self.check_get_list(url, self.super_django_user, qs) + + # an org admin can list inventories but is filtered to what he adminsters + normal_qs = qs.filter(organization__admins__in=[self.normal_django_user]) + self.check_get_list(url, self.normal_django_user, normal_qs) + + # a user who is on a team who has a read permissions on an inventory can see filtered inventories + other_qs = qs.filter(permissions__user__in=[self.other_django_user]) + self.check_get_list(url, self.other_django_user, other_qs) + + # a regular user not part of anything cannot see any inventories + nobody_qs = qs.none() + self.check_get_list(url, self.nobody_django_user, nobody_qs) + + def test_post_inventory_list(self): + url = reverse('main:inventory_list') + + # Check post to list view with invalid authentication. + new_inv_0 = dict(name='inventory-c', description='baz', organization=self.organizations[0].pk) + self.check_invalid_auth(url, new_inv_0, methods=('post',)) + + # a super user can create inventory + new_inv_1 = dict(name='inventory-c', description='baz', organization=self.organizations[0].pk) + new_id = max(Inventory.objects.values_list('pk', flat=True)) + 1 + with self.current_user(self.super_django_user): + data = self.post(url, data=new_inv_1, expect=201) + self.assertEquals(data['id'], new_id) + + # an org admin of any org can create inventory, if it is one of his organizations + # the organization parameter is required! + new_inv_incomplete = dict(name='inventory-d', description='baz') + new_inv_not_my_org = dict(name='inventory-d', description='baz', organization=self.organizations[2].pk) + new_inv_my_org = dict(name='inventory-d', description='baz', organization=self.organizations[0].pk) + with self.current_user(self.normal_django_user): + data = self.post(url, data=new_inv_incomplete, expect=400) + data = self.post(url, data=new_inv_not_my_org, expect=403) + data = self.post(url, data=new_inv_my_org, expect=201) + + # a regular user cannot create inventory + new_inv_denied = dict(name='inventory-e', description='glorp', organization=self.organizations[0].pk) + with self.current_user(self.other_django_user): + data = self.post(url, data=new_inv_denied, expect=403) + + def test_get_inventory_detail(self): + url_a = reverse('main:inventory_detail', args=(self.inventory_a.pk,)) + url_b = reverse('main:inventory_detail', args=(self.inventory_b.pk,)) + + # Check detail view with invalid authentication. + self.check_invalid_auth(url_a) + self.check_invalid_auth(url_b) + + # a super user can get inventory records + with self.current_user(self.super_django_user): + data = self.get(url_a, expect=200) + self.assertEquals(data['name'], 'inventory-a') + + # an org admin can get inventory records for his orgs only + with self.current_user(self.normal_django_user): + data = self.get(url_a, expect=200) + self.assertEquals(data['name'], 'inventory-a') + data = self.get(url_b, expect=403) + + # a user who is on a team who has read permissions on an inventory can see inventory records + with self.current_user(self.other_django_user): + data = self.get(url_a, expect=403) + data = self.get(url_b, expect=200) + self.assertEquals(data['name'], 'inventory-b') + + # a regular user cannot read any inventory records + with self.current_user(self.nobody_django_user): + data = self.get(url_a, expect=403) + data = self.get(url_b, expect=403) + + def test_put_inventory_detail(self): + url_a = reverse('main:inventory_detail', args=(self.inventory_a.pk,)) + url_b = reverse('main:inventory_detail', args=(self.inventory_b.pk,)) + + # Check put to detail view with invalid authentication. + self.check_invalid_auth(url_a, methods=('put',)) + self.check_invalid_auth(url_b, methods=('put',)) + + # a super user can update inventory records + with self.current_user(self.super_django_user): + data = self.get(url_a, expect=200) + data['name'] = 'inventory-a-update1' + self.put(url_a, data, expect=200) + data = self.get(url_b, expect=200) + data['name'] = 'inventory-b-update1' + self.put(url_b, data, expect=200) + + # an org admin can update inventory records for his orgs only. + with self.current_user(self.normal_django_user): + data = self.get(url_a, expect=200) + data['name'] = 'inventory-a-update2' + self.put(url_a, data, expect=200) + self.put(url_b, data, expect=403) + + # a user who is on a team who has read permissions on an inventory can + # see inventory records, but not update. + with self.current_user(self.other_django_user): + data = self.get(url_b, expect=200) + data['name'] = 'inventory-b-update3' + self.put(url_b, data, expect=403) + + # a regular user cannot update any inventory records + with self.current_user(self.nobody_django_user): + self.put(url_a, {}, expect=403) + self.put(url_b, {}, expect=403) + + # a superuser can reassign an inventory to another organization. + with self.current_user(self.super_django_user): + data = self.get(url_b, expect=200) + self.assertEqual(data['organization'], self.organizations[1].pk) + data['organization'] = self.organizations[0].pk + self.put(url_b, data, expect=200) + + # a normal user can't reassign an inventory to an organization where + # he isn't an admin. + with self.current_user(self.normal_django_user): + data = self.get(url_a, expect=200) + self.assertEqual(data['organization'], self.organizations[0].pk) + data['organization'] = self.organizations[1].pk + self.put(url_a, data, expect=403) def test_main_line(self): @@ -53,57 +175,57 @@ class InventoryTest(BaseTest): groups = reverse('main:group_list') # a super user can list inventories - data = self.get(inventories, expect=200, auth=self.get_super_credentials()) - self.assertEquals(data['count'], 2) + #data = self.get(inventories, expect=200, auth=self.get_super_credentials()) + #self.assertEquals(data['count'], 2) # an org admin can list inventories but is filtered to what he adminsters - data = self.get(inventories, expect=200, auth=self.get_normal_credentials()) - self.assertEquals(data['count'], 1) + #data = self.get(inventories, expect=200, auth=self.get_normal_credentials()) + #self.assertEquals(data['count'], 1) # a user who is on a team who has a read permissions on an inventory can see filtered inventories - data = self.get(inventories, expect=200, auth=self.get_other_credentials()) - self.assertEquals(data['count'], 1) + #data = self.get(inventories, expect=200, auth=self.get_other_credentials()) + #self.assertEquals(data['count'], 1) # a regular user not part of anything cannot see any inventories - data = self.get(inventories, expect=200, auth=self.get_nobody_credentials()) - self.assertEquals(data['count'], 0) + #data = self.get(inventories, expect=200, auth=self.get_nobody_credentials()) + #self.assertEquals(data['count'], 0) # a super user can get inventory records - data = self.get(inventories_1, expect=200, auth=self.get_super_credentials()) - self.assertEquals(data['name'], 'inventory-a') + #data = self.get(inventories_1, expect=200, auth=self.get_super_credentials()) + #self.assertEquals(data['name'], 'inventory-a') # an org admin can get inventory records - data = self.get(inventories_1, expect=200, auth=self.get_normal_credentials()) - self.assertEquals(data['name'], 'inventory-a') + #data = self.get(inventories_1, expect=200, auth=self.get_normal_credentials()) + #self.assertEquals(data['name'], 'inventory-a') # a user who is on a team who has read permissions on an inventory can see inventory records - data = self.get(inventories_1, expect=403, auth=self.get_other_credentials()) - data = self.get(inventories_2, expect=200, auth=self.get_other_credentials()) - self.assertEquals(data['name'], 'inventory-b') + #data = self.get(inventories_1, expect=403, auth=self.get_other_credentials()) + #data = self.get(inventories_2, expect=200, auth=self.get_other_credentials()) + #self.assertEquals(data['name'], 'inventory-b') # a regular user cannot read any inventory records - data = self.get(inventories_1, expect=403, auth=self.get_nobody_credentials()) - data = self.get(inventories_2, expect=403, auth=self.get_nobody_credentials()) + #data = self.get(inventories_1, expect=403, auth=self.get_nobody_credentials()) + #data = self.get(inventories_2, expect=403, auth=self.get_nobody_credentials()) # a super user can create inventory - new_inv_1 = dict(name='inventory-c', description='baz', organization=self.organizations[0].pk) - new_id = max(Inventory.objects.values_list('pk', flat=True)) + 1 - data = self.post(inventories, data=new_inv_1, expect=201, auth=self.get_super_credentials()) - self.assertEquals(data['id'], new_id) + #new_inv_1 = dict(name='inventory-c', description='baz', organization=self.organizations[0].pk) + #new_id = max(Inventory.objects.values_list('pk', flat=True)) + 1 + #data = self.post(inventories, data=new_inv_1, expect=201, auth=self.get_super_credentials()) + #self.assertEquals(data['id'], new_id) # an org admin of any org can create inventory, if it is one of his organizations # the organization parameter is required! - new_inv_incomplete = dict(name='inventory-d', description='baz') - data = self.post(inventories, data=new_inv_incomplete, expect=400, auth=self.get_normal_credentials()) - new_inv_not_my_org = dict(name='inventory-d', description='baz', organization=self.organizations[2].pk) + #new_inv_incomplete = dict(name='inventory-d', description='baz') + #data = self.post(inventories, data=new_inv_incomplete, expect=400, auth=self.get_normal_credentials()) + #new_inv_not_my_org = dict(name='inventory-d', description='baz', organization=self.organizations[2].pk) - data = self.post(inventories, data=new_inv_not_my_org, expect=403, auth=self.get_normal_credentials()) - new_inv_my_org = dict(name='inventory-d', description='baz', organization=self.organizations[0].pk) - data = self.post(inventories, data=new_inv_my_org, expect=201, auth=self.get_normal_credentials()) + #data = self.post(inventories, data=new_inv_not_my_org, expect=403, auth=self.get_normal_credentials()) + #new_inv_my_org = dict(name='inventory-d', description='baz', organization=self.organizations[0].pk) + #data = self.post(inventories, data=new_inv_my_org, expect=201, auth=self.get_normal_credentials()) # a regular user cannot create inventory - new_inv_denied = dict(name='inventory-e', description='glorp', organization=self.organizations[0].pk) - data = self.post(inventories, data=new_inv_denied, expect=403, auth=self.get_other_credentials()) + #new_inv_denied = dict(name='inventory-e', description='glorp', organization=self.organizations[0].pk) + #data = self.post(inventories, data=new_inv_denied, expect=403, auth=self.get_other_credentials()) # a super user can add hosts (but inventory ID is required) inv = Inventory.objects.create( diff --git a/awx/main/tests/jobs.py b/awx/main/tests/jobs.py index 73aa0b1484..50f23b6338 100644 --- a/awx/main/tests/jobs.py +++ b/awx/main/tests/jobs.py @@ -12,7 +12,7 @@ import uuid from django.contrib.auth.models import User as DjangoUser from django.conf import settings from django.core.urlresolvers import reverse -from django.db import transaction +from django.db.models import Q import django.test from django.test.client import Client from django.test.utils import override_settings @@ -427,57 +427,49 @@ class BaseJobTestMixin(BaseTestMixin): super(BaseJobTestMixin, self).setUp() self.populate() - def _test_invalid_creds(self, url, data=None, methods=None): - data = data or {} - methods = methods or ('options', 'head', 'get') - for auth in [(None,), ('invalid', 'password')]: - with self.current_user(*auth): - for method in methods: - f = getattr(self, method) - if method in ('post', 'put', 'patch'): - f(url, data, expect=401) - else: - f(url, expect=401) - class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): + JOB_TEMPLATE_FIELDS = ('id', 'url', 'related', 'summary_fields', 'created', + 'name', 'description', 'job_type', 'inventory', + 'project', 'playbook', 'credential', 'forks', + 'limit', 'verbosity', 'extra_vars', 'job_tags', + 'host_config_key',) + def test_get_job_template_list(self): url = reverse('main:job_template_list') + qs = JobTemplate.objects.distinct() + fields = self.JOB_TEMPLATE_FIELDS # Test with no auth and with invalid login. - self._test_invalid_creds(url) + self.check_invalid_auth(url) - # sue's credentials (superuser) == 200, full list - with self.current_user(self.user_sue): - self.options(url) - self.head(url) - response = self.get(url) - qs = JobTemplate.objects.all() - self.check_pagination_and_size(response, qs.count()) - self.check_list_ids(response, qs) + # Sue's credentials (superuser) == 200, full list + self.check_get_list(url, self.user_sue, qs, fields) + + # Alex's credentials (admin of all orgs) == 200, full list + self.check_get_list(url, self.user_alex, qs, fields) - # FIXME: Check individual job template result fields. - - # alex's credentials (admin of all orgs) == 200, full list - with self.current_user(self.user_alex): - self.options(url) - self.head(url) - response = self.get(url) - qs = JobTemplate.objects.all() - self.check_pagination_and_size(response, qs.count()) - self.check_list_ids(response, qs) - - # bob's credentials (admin of eng, user of ops) == 200, all from + # Bob's credentials (admin of eng, user of ops) == 200, all from # engineering and operations. - with self.current_user(self.user_bob): - self.options(url) - self.head(url) - response = self.get(url) - qs = JobTemplate.objects.filter( - inventory__organization__in=[self.org_eng, self.org_ops], + bob_qs = qs.filter( + Q(project__organizations__admins__in=[self.user_bob]) | + Q(project__teams__users__in=[self.user_bob]), ) - #self.check_pagination_and_size(response, qs.count()) - #self.check_list_ids(response, qs) + self.check_get_list(url, self.user_bob, bob_qs, fields) + + # Chuck's credentials (admin of eng) == 200, all from engineering. + chuck_qs = qs.filter( + Q(project__organizations__admins__in=[self.user_chuck]) | + Q(project__teams__users__in=[self.user_chuck]), + ) + self.check_get_list(url, self.user_chuck, chuck_qs, fields) + + # Doug's credentials (user of eng) == 200, none?. + doug_qs = qs.filter( + Q(project__organizations__admins__in=[self.user_doug]) | + Q(project__teams__users__in=[self.user_doug]), + ) + self.check_get_list(url, self.user_doug, doug_qs, fields) # FIXME: Check with other credentials. @@ -492,7 +484,7 @@ class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): ) # Test with no auth and with invalid login. - self._test_invalid_creds(url, data, methods=('post',)) + self.check_invalid_auth(url, data, methods=('post',)) # sue can always add job templates. with self.current_user(self.user_sue): @@ -541,7 +533,7 @@ class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): url = reverse('main:job_template_detail', args=(jt.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url) + self.check_invalid_auth(url) # sue can read the job template detail. with self.current_user(self.user_sue): @@ -562,7 +554,7 @@ class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): url = reverse('main:job_template_detail', args=(jt.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url, methods=('put',))# 'patch')) + self.check_invalid_auth(url, methods=('put',))# 'patch')) # sue can update the job template detail. with self.current_user(self.user_sue): @@ -579,7 +571,7 @@ class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): url = reverse('main:job_template_jobs_list', args=(jt.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url) + self.check_invalid_auth(url) # sue can read the job template job list. with self.current_user(self.user_sue): @@ -601,7 +593,7 @@ class JobTemplateTest(BaseJobTestMixin, django.test.TestCase): ) # Test with no auth and with invalid login. - self._test_invalid_creds(url, data, methods=('post',)) + self.check_invalid_auth(url, data, methods=('post',)) # sue can create a new job from the template. with self.current_user(self.user_sue): @@ -615,7 +607,7 @@ class JobTest(BaseJobTestMixin, django.test.TestCase): url = reverse('main:job_list') # Test with no auth and with invalid login. - self._test_invalid_creds(url) + self.check_invalid_auth(url) # sue's credentials (superuser) == 200, full list with self.current_user(self.user_sue): @@ -641,7 +633,7 @@ class JobTest(BaseJobTestMixin, django.test.TestCase): ) # Test with no auth and with invalid login. - self._test_invalid_creds(url, data, methods=('post',)) + self.check_invalid_auth(url, data, methods=('post',)) # sue can create a new job without a template. with self.current_user(self.user_sue): @@ -663,7 +655,7 @@ class JobTest(BaseJobTestMixin, django.test.TestCase): url = reverse('main:job_detail', args=(job.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url) + self.check_invalid_auth(url) # sue can read the job detail. with self.current_user(self.user_sue): @@ -679,7 +671,7 @@ class JobTest(BaseJobTestMixin, django.test.TestCase): url = reverse('main:job_detail', args=(job.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url, methods=('put',))# 'patch')) + self.check_invalid_auth(url, methods=('put',))# 'patch')) # sue can update the job detail only if the job is new. self.assertEqual(job.status, 'new') @@ -780,8 +772,8 @@ class JobStartCancelTest(BaseJobTestMixin, django.test.LiveServerTestCase): url = reverse('main:job_start', args=(job.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url) - self._test_invalid_creds(url, methods=('post',)) + self.check_invalid_auth(url) + self.check_invalid_auth(url, methods=('post',)) # Sue can start a job (when passwords are already saved) as long as the # status is new. Reverse list so "new" will be last. @@ -867,8 +859,8 @@ class JobStartCancelTest(BaseJobTestMixin, django.test.LiveServerTestCase): url = reverse('main:job_cancel', args=(job.pk,)) # Test with no auth and with invalid login. - self._test_invalid_creds(url) - self._test_invalid_creds(url, methods=('post',)) + self.check_invalid_auth(url) + self.check_invalid_auth(url, methods=('post',)) # sue can cancel the job, but only when it is pending or running. for status in [x[0] for x in Job.STATUS_CHOICES]: diff --git a/awx/main/tests/projects.py b/awx/main/tests/projects.py index 9e6ea70834..2bd81553e0 100644 --- a/awx/main/tests/projects.py +++ b/awx/main/tests/projects.py @@ -84,14 +84,6 @@ class ProjectsTest(BaseTest): self.team1.users.add(self.normal_django_user) self.team2.users.add(self.other_django_user) - self.nobody_django_user = User.objects.create(username='nobody') - self.nobody_django_user.set_password('nobody') - self.nobody_django_user.save() - - def get_nobody_credentials(self): - # here is a user without any permissions... - return ('nobody', 'nobody') - def test_playbooks(self): def write_test_file(project, name, content): full_path = os.path.join(project.get_project_path(), name) @@ -395,11 +387,13 @@ class ProjectsTest(BaseTest): self.get(url, expect=401, auth=self.get_invalid_credentials()) self.get(url, expect=403, auth=self.get_nobody_credentials()) other.organizations.add(Organization.objects.get(pk=self.organizations[1].pk)) - other.save() + # Normal user can only see some teams that other user is a part of, + # since normal user is not an admin of that organization. my_teams1 = self.get(url, expect=200, auth=self.get_normal_credentials()) + self.assertEqual(my_teams1['count'], 1) + # Other user should be able to see all his own teams. my_teams2 = self.get(url, expect=200, auth=self.get_other_credentials()) - self.assertEqual(my_teams1['count'], 2) - self.assertEqual(my_teams1, my_teams2) + self.assertEqual(my_teams2['count'], 2) # ===================================================================== # USER PROJECTS @@ -511,14 +505,14 @@ class ProjectsTest(BaseTest): team_url = reverse('main:team_credentials_list', args=(cred_put_t['team'],)) self.post(team_url, data=cred_put_t, expect=204, auth=self.get_normal_credentials()) - # can remove credentials from a user (via disassociate) + # can remove credentials from a user (via disassociate) - this will delete the credential. cred_put_u['disassociate'] = 1 url = cred_put_u['url'] user_url = reverse('main:user_credentials_list', args=(cred_put_u['user'],)) self.post(user_url, data=cred_put_u, expect=204, auth=self.get_normal_credentials()) # can delete a credential directly -- probably won't be used too often - data = self.delete(url, expect=204, auth=self.get_other_credentials()) + #data = self.delete(url, expect=204, auth=self.get_other_credentials()) data = self.delete(url, expect=404, auth=self.get_other_credentials()) # ===================================================================== diff --git a/awx/main/tests/users.py b/awx/main/tests/users.py index d4d3b88b9b..cd6f3e5efd 100644 --- a/awx/main/tests/users.py +++ b/awx/main/tests/users.py @@ -142,7 +142,7 @@ class UsersTest(BaseTest): def test_user_list_filtered(self): url = reverse('main:user_list') data3 = self.get(url, expect=200, auth=self.get_super_credentials()) - self.assertEquals(data3['count'], 3) + self.assertEquals(data3['count'], 4) data2 = self.get(url, expect=200, auth=self.get_normal_credentials()) self.assertEquals(data2['count'], 2) data1 = self.get(url, expect=200, auth=self.get_other_credentials()) diff --git a/awx/main/urls.py b/awx/main/urls.py index 229e0c31bb..d9eca0a57e 100644 --- a/awx/main/urls.py +++ b/awx/main/urls.py @@ -170,6 +170,7 @@ try: # Support for get_description method on views compatible with 2.2.x. if hasattr(cls, 'get_description') and callable(cls.get_description): desc = cls().get_description(html=html) + cls = type(cls.__name__, (object,), {'__doc__': desc}) elif hasattr(cls, 'view_description'): if callable(cls.view_description): view_desc = cls.view_description() diff --git a/awx/main/utils.py b/awx/main/utils.py new file mode 100644 index 0000000000..0bdf7fe1ad --- /dev/null +++ b/awx/main/utils.py @@ -0,0 +1,54 @@ +# Python +import logging +import re +import sys + +# Django +from django.conf import settings +from django.shortcuts import _get_queryset + +# Django REST Framework +from rest_framework.exceptions import ParseError, PermissionDenied + +__all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore'] + +def get_object_or_400(klass, *args, **kwargs): + ''' + Return a single object from the given model or queryset based on the query + params, otherwise raise an exception that will return in a 400 response. + ''' + queryset = _get_queryset(klass) + try: + return queryset.get(*args, **kwargs) + except queryset.model.DoesNotExist, e: + raise ParseError(*e.args) + except queryset.model.MultipleObjectsReturned, e: + raise ParseError(*e.args) + +def get_object_or_403(klass, *args, **kwargs): + ''' + Return a single object from the given model or queryset based on the query + params, otherwise raise an exception that will return in a 403 response. + ''' + queryset = _get_queryset(klass) + try: + return queryset.get(*args, **kwargs) + except queryset.model.DoesNotExist, e: + raise PermissionDenied(*e.args) + except queryset.model.MultipleObjectsReturned, e: + raise PermissionDenied(*e.args) + +def camelcase_to_underscore(s): + ''' + Convert CamelCase names to lowercase_with_underscore. + ''' + s = re.sub(r'(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', '_\\1', s) + return s.lower().strip('_') + +class RequireDebugTrueOrTest(logging.Filter): + ''' + Logging filter to output when in DEBUG mode or running tests. + ''' + + def filter(self, record): + return settings.DEBUG or 'test' in sys.argv diff --git a/awx/main/views.py b/awx/main/views.py index 5570859287..1906b3594a 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -32,6 +32,7 @@ from awx.main.base_views import * from awx.main.models import * from awx.main.permissions import * from awx.main.serializers import * +from awx.main.utils import * def handle_error(request, status=404): context = {} @@ -189,7 +190,6 @@ class OrganizationUsersList(SubListCreateAPIView): serializer_class = UserSerializer parent_model = Organization relationship = 'users' - inject_primary_key_on_post_as = 'organization' class OrganizationAdminsList(SubListCreateAPIView): @@ -197,8 +197,6 @@ class OrganizationAdminsList(SubListCreateAPIView): serializer_class = UserSerializer parent_model = Organization relationship = 'admins' - inject_primary_key_on_post_as = 'organization' - class OrganizationProjectsList(SubListCreateAPIView): @@ -206,7 +204,6 @@ class OrganizationProjectsList(SubListCreateAPIView): serializer_class = ProjectSerializer parent_model = Organization relationship = 'projects' - inject_primary_key_on_post_as = 'organization' class OrganizationTeamsList(SubListCreateAPIView): @@ -214,8 +211,7 @@ class OrganizationTeamsList(SubListCreateAPIView): serializer_class = TeamSerializer parent_model = Organization relationship = 'teams' - inject_primary_key_on_post_as = 'organization' - severable = False + parent_key = 'organization' class TeamList(ListCreateAPIView): @@ -233,8 +229,6 @@ class TeamUsersList(SubListCreateAPIView): serializer_class = UserSerializer parent_model = Team relationship = 'users' - inject_primary_key_on_post_as = 'team' - severable = True class TeamPermissionsList(SubListCreateAPIView): @@ -242,7 +236,7 @@ class TeamPermissionsList(SubListCreateAPIView): serializer_class = PermissionSerializer parent_model = Team relationship = 'permissions' - inject_primary_key_on_post_as = 'team' + parent_key = 'team' def get_queryset(self): # FIXME @@ -261,8 +255,6 @@ class TeamProjectsList(SubListCreateAPIView): serializer_class = ProjectSerializer parent_model = Team relationship = 'projects' - inject_primary_key_on_post_as = 'team' - severable = True class TeamCredentialsList(SubListCreateAPIView): @@ -270,7 +262,7 @@ class TeamCredentialsList(SubListCreateAPIView): serializer_class = CredentialSerializer parent_model = Team relationship = 'credentials' - inject_primary_key_on_post_as = 'team' + parent_key = 'team' class ProjectList(ListCreateAPIView): @@ -293,7 +285,6 @@ class ProjectOrganizationsList(SubListCreateAPIView): serializer_class = OrganizationSerializer parent_model = Project relationship = 'organizations' - inject_primary_key_on_post_as = 'project' # Not correct, but needed for the post to work? def get_queryset(self): # FIXME @@ -308,7 +299,6 @@ class ProjectTeamsList(SubListCreateAPIView): serializer_class = TeamSerializer parent_model = Project relationship = 'teams' - inject_primary_key_on_post_as = 'project' # Not correct, but needed for the post to work? def get_queryset(self): project = Project.objects.get(pk=self.kwargs['pk']) @@ -347,6 +337,7 @@ class UserTeamsList(SubListAPIView): serializer_class = TeamSerializer parent_model = User relationship = 'teams' + parent_access = 'read' class UserPermissionsList(SubListCreateAPIView): @@ -354,7 +345,8 @@ class UserPermissionsList(SubListCreateAPIView): serializer_class = PermissionSerializer parent_model = User relationship = 'permissions' - inject_primary_key_on_post_as = 'user' + parent_key = 'user' + parent_access = 'read' class UserProjectsList(SubListAPIView): @@ -362,6 +354,7 @@ class UserProjectsList(SubListAPIView): serializer_class = ProjectSerializer parent_model = User relationship = 'projects' + parent_access = 'read' def get_queryset(self): parent = self.get_parent_object() @@ -375,7 +368,8 @@ class UserCredentialsList(SubListCreateAPIView): serializer_class = CredentialSerializer parent_model = User relationship = 'credentials' - inject_primary_key_on_post_as = 'user' + parent_key = 'user' + parent_access = 'read' class UserOrganizationsList(SubListAPIView): @@ -383,6 +377,7 @@ class UserOrganizationsList(SubListAPIView): serializer_class = OrganizationSerializer parent_model = User relationship = 'organizations' + parent_access = 'read' class UserAdminOfOrganizationsList(SubListAPIView): @@ -390,6 +385,7 @@ class UserAdminOfOrganizationsList(SubListAPIView): serializer_class = OrganizationSerializer parent_model = User relationship = 'admin_of_organizations' + parent_access = 'read' class UserDetail(RetrieveUpdateDestroyAPIView): @@ -399,8 +395,9 @@ class UserDetail(RetrieveUpdateDestroyAPIView): def update_filter(self, request, *args, **kwargs): ''' make sure non-read-only fields that can only be edited by admins, are only edited by admins ''' obj = User.objects.get(pk=kwargs['pk']) + can_change = request.user.can_access(User, 'change', obj, request.DATA) can_admin = request.user.can_access(User, 'admin', obj, request.DATA) - if not can_admin or can_admin == 'partial': + if can_change and not can_admin: admin_only_edit_fields = ('last_name', 'first_name', 'username', 'is_active', 'is_superuser') changed = {} @@ -412,10 +409,10 @@ class UserDetail(RetrieveUpdateDestroyAPIView): if changed: raise PermissionDenied('Cannot change %s' % ', '.join(changed.keys())) - if 'password' in request.DATA and request.DATA['password']: - obj.set_password(request.DATA['password']) + new_password = request.DATA.get('password', '') + if can_change and new_password: + obj.set_password(new_password) obj.save() - request.DATA.pop('password') class CredentialList(ListAPIView): @@ -459,8 +456,7 @@ class InventoryHostsList(SubListCreateAPIView): parent_model = Inventory relationship = 'hosts' parent_access = 'read' - inject_primary_key_on_post_as = 'inventory' - severable = False + parent_key = 'inventory' class HostGroupsList(SubListCreateAPIView): ''' the list of groups a host is directly a member of ''' @@ -470,7 +466,6 @@ class HostGroupsList(SubListCreateAPIView): parent_model = Host relationship = 'groups' parent_access = 'read' - inject_primary_key_on_post_as = 'host' class HostAllGroupsList(SubListAPIView): ''' the list of all groups of which the host is directly or indirectly a member ''' @@ -500,7 +495,6 @@ class GroupChildrenList(SubListCreateAPIView): parent_model = Group relationship = 'children' parent_access = 'read' - inject_primary_key_on_post_as = 'parent' class GroupHostsList(SubListCreateAPIView): ''' the list of hosts directly below a group ''' @@ -510,7 +504,6 @@ class GroupHostsList(SubListCreateAPIView): parent_model = Group relationship = 'hosts' parent_access = 'read' - inject_primary_key_on_post_as = 'group' class GroupAllHostsList(SubListAPIView): ''' the list of all hosts below a group, even including subgroups ''' @@ -540,8 +533,7 @@ class InventoryGroupsList(SubListCreateAPIView): parent_model = Inventory relationship = 'groups' parent_access = 'read' - inject_primary_key_on_post_as = 'inventory' - severable = False + parent_key = 'inventory' class InventoryRootGroupsList(SubListCreateAPIView): @@ -550,8 +542,7 @@ class InventoryRootGroupsList(SubListCreateAPIView): parent_model = Inventory relationship = 'groups' parent_access = 'read' - inject_primary_key_on_post_as = 'inventory' - severable = False + parent_key = 'inventory' def get_queryset(self): parent = self.get_parent_object() @@ -790,8 +781,7 @@ class JobTemplateJobsList(SubListCreateAPIView): serializer_class = JobSerializer parent_model = JobTemplate relationship = 'jobs' - inject_primary_key_on_post_as = 'job_template' - severable = False + parent_key = 'job_template' class JobList(ListCreateAPIView): @@ -955,7 +945,6 @@ class JobJobEventsList(BaseJobEventsList): # in URL patterns and reverse URL lookups, converting CamelCase names to # lowercase_with_underscore (e.g. MyView.as_view() becomes my_view). this_module = sys.modules[__name__] -camelcase_to_underscore = lambda str: re.sub(r'(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', '_\\1', str).lower().strip('_') for attr, value in locals().items(): if isinstance(value, type) and issubclass(value, APIView): name = camelcase_to_underscore(attr) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 2f6a9284b8..a36ddada3c 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -278,6 +278,9 @@ LOGGING = { 'require_debug_true': { '()': 'awx.main.compat.RequireDebugTrue', }, + 'require_debug_true_or_test': { + '()': 'awx.main.utils.RequireDebugTrueOrTest', + }, }, 'formatters': { 'simple': { @@ -287,7 +290,7 @@ LOGGING = { 'handlers': { 'console': { 'level': 'DEBUG', - 'filters': ['require_debug_true'], + 'filters': ['require_debug_true_or_test'], 'class': 'logging.StreamHandler', 'formatter': 'simple', }, @@ -328,12 +331,10 @@ LOGGING = { }, 'awx.main.permissions': { 'handlers': ['null'], - # Comment the line below to show lots of permissions logging. 'propagate': False, }, 'awx.main.access': { 'handlers': ['null'], - # Comment the line below to show lots of permissions logging. 'propagate': False, }, } diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example index 88c1f5897b..a28ef37ff3 100644 --- a/awx/settings/local_settings.py.example +++ b/awx/settings/local_settings.py.example @@ -106,6 +106,11 @@ LOGGING['handlers']['syslog'] = { # 'formatter': 'simple', #} +# Enable the following lines to turn on lots of permissions-related logging. +#LOGGING['loggers']['awx.main.access']['propagate'] = True +#LOGGING['loggers']['awx.main.permissions']['propagate'] = True + # Define additional environment variables to be passed to subprocess started by # the celery task. #AWX_TASK_ENV['FOO'] = 'BAR' +