diff --git a/awx/main/access.py b/awx/main/access.py index 201e8d59f3..bfa28e054e 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -9,6 +9,8 @@ import logging # Django from django.db.models import F, Q from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.db.models.aggregates import Max # Django REST Framework from rest_framework.exceptions import ParseError, PermissionDenied @@ -16,11 +18,13 @@ from rest_framework.exceptions import ParseError, PermissionDenied # AWX from awx.main.utils import * # noqa from awx.main.models import * # noqa +from awx.main.models.mixins import ResourceMixin from awx.main.models.rbac import ALL_PERMISSIONS from awx.api.license import LicenseForbids from awx.main.task_engine import TaskSerializer -__all__ = ['get_user_queryset', 'check_user_access'] +__all__ = ['get_user_queryset', 'check_user_access', + 'user_accessible_objects', 'user_accessible_by'] PERMISSION_TYPES = [ PERM_INVENTORY_ADMIN, @@ -71,6 +75,26 @@ def register_access(model_class, access_class): access_classes = access_registry.setdefault(model_class, []) access_classes.append(access_class) +def user_accessible_objects(user, permissions): + content_type = ContentType.objects.get_for_model(User) + qs = RolePermission.objects.filter( + content_type=content_type, + role__ancestors__members=user + ) + for perm in permissions: + qs = qs.annotate(**{'max_' + perm: Max(perm)}) + qs = qs.filter(**{'max_' + perm: int(permissions[perm])}) + return qs + +def user_accessible_by(instance, user, permissions): + perms = get_user_permissions_on_resource(instance, user) + if perms is None: + return False + for k in permissions: + if k not in perms or perms[k] < permissions[k]: + return False + return True + def get_user_queryset(user, model_class): ''' Return a queryset for the given model_class containing only the instances diff --git a/awx/main/fields.py b/awx/main/fields.py index ee02c27440..77c6e3a489 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -181,63 +181,49 @@ class ImplicitRoleField(models.ForeignKey): def bind_m2m_changed(self, _self, _role_class, cls): if not self.parent_role: return + field_names = self.parent_role if type(field_names) is not list: field_names = [field_names] - found_m2m_field = False for field_name in field_names: if field_name.startswith('singleton:'): continue - first_field_name = field_name.split('.')[0] - field = getattr(cls, first_field_name) + field_name, sep, field_attr = field_name.partition('.') + field = getattr(cls, field_name) if type(field) is ReverseManyRelatedObjectsDescriptor or \ type(field) is ManyRelatedObjectsDescriptor: - if found_m2m_field: - # This limitation is due to a lack of understanding on my part, the - # trouble being that I can't seem to get m2m_changed to call anything that - # encapsulates what field we're working with. So the workaround that I've - # settled on for the time being is to simply iterate over the fields in the - # m2m_update and look for the m2m map and update accordingly. This solution - # is lame, I'd love for someone to show me the way to get the necessary - # state down to the m2m_update callback. - anoek 2016-02-10 - raise Exception('Multiple ManyToMany fields not allowed in parent_role list') - if len(field_name.split('.')) != 2: + + if '.' in field_attr: raise Exception('Referencing deep roles through ManyToMany fields is unsupported.') - found_m2m_field = True - self.m2m_field_name = first_field_name - self.m2m_field_attr = field_name.split('.',1)[1] - - if type(field) is ReverseManyRelatedObjectsDescriptor: - m2m_changed.connect(self.m2m_update, field.through) + reverse = type(field) is ReverseManyRelatedObjectsDescriptor + if reverse: + m2m_changed.connect(self.m2m_update(field_attr, reverse), field.through) else: - m2m_changed.connect(self.m2m_update_related, field.related.through) + m2m_changed.connect(self.m2m_update(field_attr, reverse), field.related.through) + def m2m_update(self, field_attr, _reverse): + def _m2m_update(self, sender, instance, action, reverse, model, pk_set, **kwargs): + if action == 'post_add' or action == 'pre_remove': + if _reverse: + for pk in pk_set: + obj = model.objects.get(pk=pk) + if action == 'post_add': + getattr(instance, self.name).children.add(getattr(obj, field_attr)) + if action == 'pre_remove': + getattr(instance, self.name).children.remove(getattr(obj, field_attr)) - def m2m_update_related(self, **kwargs): - kwargs['reverse'] = not kwargs['reverse'] - self.m2m_update(**kwargs) - - def m2m_update(self, sender, instance, action, reverse, model, pk_set, **kwargs): - if action == 'post_add' or action == 'pre_remove': - if reverse: - for pk in pk_set: - obj = model.objects.get(pk=pk) - if action == 'post_add': - getattr(instance, self.name).children.add(getattr(obj, self.m2m_field_attr)) - if action == 'pre_remove': - getattr(instance, self.name).children.remove(getattr(obj, self.m2m_field_attr)) - - else: - for pk in pk_set: - obj = model.objects.get(pk=pk) - if action == 'post_add': - getattr(instance, self.name).parents.add(getattr(obj, self.m2m_field_attr)) - if action == 'pre_remove': - getattr(instance, self.name).parents.remove(getattr(obj, self.m2m_field_attr)) + else: + for pk in pk_set: + obj = model.objects.get(pk=pk) + if action == 'post_add': + getattr(instance, self.name).parents.add(getattr(obj, field_attr)) + if action == 'pre_remove': + getattr(instance, self.name).parents.remove(getattr(obj, field_attr)) + return _m2m_update def _post_init(self, instance, *args, **kwargs): diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 749807ac74..508d50037c 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -41,6 +41,8 @@ from awx.main.access import * # noqa User.add_to_class('get_queryset', get_user_queryset) User.add_to_class('can_access', check_user_access) +User.add_to_class('accessible_by', user_accessible_by) +User.add_to_class('accessible_objects', user_accessible_objects) # Import signal handlers only after models have been defined. import awx.main.signals # noqa diff --git a/awx/main/signals.py b/awx/main/signals.py index 7421de462c..4e196eaaef 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -130,13 +130,22 @@ def sync_superuser_status_to_rbac(sender, instance, **kwargs): else: Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.remove(instance) -def create_user_resource(sender, **kwargs): +def create_user_role(sender, **kwargs): instance = kwargs['instance'] try: - UserResource.objects.get(user=instance) - except UserResource.DoesNotExist: - ur = UserResource.objects.create(user=instance) - ur.admin_role.members.add(instance) + Role.objects.get(content_type=ContentType.objects.get_for_model(User), object_id=instance.id) + except Role.DoesNotExist: + role = Role.objects.create( + singleton_name = '%s-admin_role' % instance.username, + content_object = instance, + ) + role.members.add(instance) + RolePermission.objects.create( + role = role, + resource = instance, + create=1, read=1, write=1, delete=1, update=1, + execute=1, scm_update=1, use=1, + ) pre_save.connect(store_initial_active_state, sender=Host) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -158,7 +167,7 @@ post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) post_save.connect(sync_superuser_status_to_rbac, sender=User) -post_save.connect(create_user_resource, sender=User) +post_save.connect(create_user_role, sender=User) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py index c2a41769f5..9a8707966f 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -1,8 +1,11 @@ import pytest -from awx.main.migrations import _rbac as rbac -from awx.main.models import Role from django.apps import apps +from django.contrib.auth.models import User + +from awx.main.migrations import _rbac as rbac +from awx.main.access import UserAccess +from awx.main.models import Role @pytest.mark.django_db def test_user_admin(user_project, project, user): @@ -23,3 +26,21 @@ def test_user_admin(user_project, project, user): assert sa.members.filter(id=joe.id).exists() is False assert sa.members.filter(id=admin.id).exists() is True assert len(migrations) == 1 + +@pytest.mark.django_db +def test_user_queryset(user): + u = user('pete', False) + + access = UserAccess(u) + qs = access.get_queryset() + assert qs.count() == 1 + +@pytest.mark.django_db +def test_user_accessible_by(user, organization): + admin = user('admin', False) + u = user('john', False) + + organization.member_role.members.add(u) + organization.admin_role.members.add(admin) + + assert User.accessible_objects(admin, {'read':True}).count() == 2