diff --git a/.travis.yml b/.travis.yml index 20ff723f0d..7c815ff821 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ python: - 2.7 env: global: - - TEST_APPS="authentication django_gpg document_indexing document_signatures documents dynamic_search folders lock_manager ocr permissions sources tags" + - TEST_APPS="acls authentication django_gpg document_indexing document_signatures documents dynamic_search folders lock_manager ocr permissions sources tags" matrix: - DB=mysql - DB=postgres diff --git a/mayan/apps/acls/classes.py b/mayan/apps/acls/classes.py index 08ed36eaa4..1cd9ef2d0f 100644 --- a/mayan/apps/acls/classes.py +++ b/mayan/apps/acls/classes.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__name__) class ModelPermission(object): _registry = {} + _proxies = {} + _inheritances = {} @classmethod def register(cls, model, permissions): @@ -18,6 +20,25 @@ class ModelPermission(object): @classmethod def get_for_instance(cls, instance): - permissions = cls._registry.get(type(instance), ()) + try: + permissions = cls._registry[type(instance)] + except KeyError: + try: + permissions = cls._registry[cls._proxies[type(instance)]] + except KeyError: + permissions = () + pks = [permission.stored_permission.pk for permission in permissions] return StoredPermission.objects.filter(pk__in=pks) + + @classmethod + def register_proxy(cls, source, model): + cls._proxies[model] = source + + @classmethod + def register_inheritance(cls, model, related): + cls._inheritances[model] = related + + @classmethod + def get_inheritance(cls, model): + return cls._inheritances[model] diff --git a/mayan/apps/acls/managers.py b/mayan/apps/acls/managers.py index 5c6f9e5365..27c547f12e 100644 --- a/mayan/apps/acls/managers.py +++ b/mayan/apps/acls/managers.py @@ -1,12 +1,17 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals import logging from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.db import models +from django.db.models import Q from django.utils.translation import ugettext +from permissions.models import StoredPermission + +from .classes import ModelPermission + logger = logging.getLogger(__name__) @@ -16,20 +21,44 @@ class AccessControlListManager(models.Manager): and an object """ + def get_inherited_permissions(self, role, obj): + try: + instance = obj.first() + except AttributeError: + instance = obj + else: + if not instance: + return StoredPermission.objects.none() + + try: + parent_accessor = ModelPermission.get_inheritance(type(instance)) + except KeyError: + return StoredPermission.objects.none() + else: + parent_object = getattr(instance, parent_accessor) + content_type = ContentType.objects.get_for_model(parent_object) + try: + return self.get(role=role, content_type=content_type, object_id=parent_object.pk).permissions.all() + except self.model.DoesNotExist: + return StoredPermission.objects.none() + def check_access(self, permissions, user, obj): if user.is_superuser or user.is_staff: return True - user_roles = [] - for group in user.groups.all(): - for role in group.roles.all(): - user_roles.append(role) - try: stored_permissions = [permission.stored_permission for permission in permissions] except TypeError: stored_permissions = [permissions.stored_permission] + user_roles = [] + for group in user.groups.all(): + for role in group.roles.all(): + if set(stored_permissions).intersection(set(self.get_inherited_permissions(role=role, obj=obj))): + return True + + user_roles.append(role) + if not self.filter(content_type=ContentType.objects.get_for_model(obj), object_id=obj.pk, permissions__in=stored_permissions, role__in=user_roles): raise PermissionDenied(ugettext('Insufficient access.')) @@ -42,11 +71,21 @@ class AccessControlListManager(models.Manager): for role in group.roles.all(): user_roles.append(role) + parent_accessor = ModelPermission.get_inheritance(queryset.model) + instance = queryset.first() + if instance: + parent_object = getattr(instance, parent_accessor) + parent_content_type = ContentType.objects.get_for_model(parent_object) + parent_queryset = self.filter(content_type=parent_content_type, role__in=user_roles, permissions=permission.stored_permission) + parent_acl_query = Q(**{'{}__pk__in'.format(parent_accessor): parent_queryset.values_list('pk', flat=True)}) + else: + parent_acl_query = Q() + + # Directly granted access content_type = ContentType.objects.get_for_model(queryset.model) + acl_query = Q(pk__in=self.filter(content_type=content_type, role__in=user_roles, permissions=permission.stored_permission).values_list('object_id', flat=True)) - acls = self.filter(content_type=content_type, role__in=user_roles, permissions=permission.stored_permission).values_list('object_id', flat=True) - - new_queryset = queryset.filter(pk__in=acls) + new_queryset = queryset.filter(parent_acl_query | acl_query) if new_queryset.count() == 0 and exception_on_empty: raise PermissionDenied diff --git a/mayan/apps/acls/models.py b/mayan/apps/acls/models.py index 010b848e36..d308bef4ce 100644 --- a/mayan/apps/acls/models.py +++ b/mayan/apps/acls/models.py @@ -10,6 +10,7 @@ from django.utils.translation import ugettext_lazy as _ from permissions.models import Role, StoredPermission +from .classes import ModelPermission from .managers import AccessControlListManager logger = logging.getLogger(__name__) @@ -43,3 +44,6 @@ class AccessControlList(models.Model): def __str__(self): return '{} <=> {}'.format(self.content_object, self.role) + + def get_inherited_permissions(self): + return AccessControlList.objects.get_inherited_permissions(role=self.role, obj=self.content_object) diff --git a/mayan/apps/acls/test_models.py b/mayan/apps/acls/test_models.py new file mode 100644 index 0000000000..bbab981630 --- /dev/null +++ b/mayan/apps/acls/test_models.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import, unicode_literals + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied +from django.core.files import File +from django.core.urlresolvers import reverse +from django.test.client import Client +from django.test import TestCase + +from documents.models import Document, DocumentType +from documents.permissions import permission_document_view +from documents.test_models import ( + TEST_ADMIN_PASSWORD, TEST_ADMIN_USERNAME, TEST_ADMIN_EMAIL, + TEST_SMALL_DOCUMENT_FILENAME, TEST_NON_ASCII_DOCUMENT_FILENAME, + TEST_NON_ASCII_COMPRESSED_DOCUMENT_FILENAME, TEST_DOCUMENT_PATH, + TEST_SIGNED_DOCUMENT_PATH, TEST_SMALL_DOCUMENT_PATH, + TEST_NON_ASCII_DOCUMENT_PATH, TEST_NON_ASCII_COMPRESSED_DOCUMENT_PATH, + TEST_DOCUMENT_DESCRIPTION, TEST_DOCUMENT_TYPE +) +from permissions.classes import Permission +from permissions.models import Role + +from .models import AccessControlList + + +class PermissionTestCase(TestCase): + def setUp(self): + self.document_type_1 = DocumentType.objects.create(label=TEST_DOCUMENT_TYPE) + + ocr_settings = self.document_type_1.ocr_settings + ocr_settings.auto_ocr = False + ocr_settings.save() + + self.document_type_2 = DocumentType.objects.create(label=TEST_DOCUMENT_TYPE + '2') + + ocr_settings = self.document_type_2.ocr_settings + ocr_settings.auto_ocr = False + ocr_settings.save() + + with open(TEST_SMALL_DOCUMENT_PATH) as file_object: + self.document_1 = self.document_type_1.new_document(file_object=File(file_object), label='document 1') + + with open(TEST_SMALL_DOCUMENT_PATH) as file_object: + self.document_2 = self.document_type_1.new_document(file_object=File(file_object), label='document 2') + + with open(TEST_SMALL_DOCUMENT_PATH) as file_object: + self.document_3 = self.document_type_2.new_document(file_object=File(file_object), label='document 3') + + self.user = get_user_model().objects.create(username='test user') + self.group = Group.objects.create(name='test group') + self.role = Role.objects.create(label='test role') + Permission.invalidate_cache() + + def test_check_access_without_permissions(self): + with self.assertRaises(PermissionDenied): + AccessControlList.objects.check_access(permissions=(permission_document_view,), user=self.user, obj=self.document_1) + + def test_filtering_without_permissions(self): + self.assertEqual( + list(AccessControlList.objects.filter_by_access(permission=permission_document_view, user=self.user, queryset=Document.objects.all())), + [] + ) + + def test_check_access_with_acl(self): + self.group.user_set.add(self.user) + self.role.groups.add(self.group) + + acl = AccessControlList.objects.create(content_object=self.document_1, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + try: + AccessControlList.objects.check_access(permissions=(permission_document_view,), user=self.user, obj=self.document_1) + except PermissionDenied: + self.fail('PermissionDenied exception was not expected.') + + def test_filtering_with_permissions(self): + self.group.user_set.add(self.user) + self.role.permissions.add(permission_document_view.stored_permission) + self.role.groups.add(self.group) + + acl = AccessControlList.objects.create(content_object=self.document_1, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + self.assertEqual( + list(AccessControlList.objects.filter_by_access(permission=permission_document_view, user=self.user, queryset=Document.objects.all())), + [self.document_1] + ) + + def test_check_access_with_inherited_acl(self): + self.group.user_set.add(self.user) + self.role.groups.add(self.group) + + acl = AccessControlList.objects.create(content_object=self.document_type_1, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + try: + AccessControlList.objects.check_access(permissions=(permission_document_view,), user=self.user, obj=self.document_1) + except PermissionDenied: + self.fail('PermissionDenied exception was not expected.') + + def test_check_access_with_inherited_acl_and_local_acl(self): + self.group.user_set.add(self.user) + self.role.groups.add(self.group) + + acl = AccessControlList.objects.create(content_object=self.document_type_1, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + acl = AccessControlList.objects.create(content_object=self.document_3, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + try: + AccessControlList.objects.check_access(permissions=(permission_document_view,), user=self.user, obj=self.document_3) + except PermissionDenied: + self.fail('PermissionDenied exception was not expected.') + + def test_filtering_with_inherited_permissions(self): + self.group.user_set.add(self.user) + self.role.permissions.add(permission_document_view.stored_permission) + self.role.groups.add(self.group) + + acl = AccessControlList.objects.create(content_object=self.document_type_1, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + result = AccessControlList.objects.filter_by_access(permission=permission_document_view, user=self.user, queryset=Document.objects.all()) + self.assertTrue(self.document_1 in result) + self.assertTrue(self.document_2 in result) + self.assertTrue(self.document_3 not in result) + + + def test_filtering_with_inherited_permissions_and_local_acl(self): + self.group.user_set.add(self.user) + self.role.permissions.add(permission_document_view.stored_permission) + self.role.groups.add(self.group) + + acl = AccessControlList.objects.create(content_object=self.document_type_1, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + acl = AccessControlList.objects.create(content_object=self.document_3, role=self.role) + acl.permissions.add(permission_document_view.stored_permission) + + result = AccessControlList.objects.filter_by_access(permission=permission_document_view, user=self.user, queryset=Document.objects.all()) + self.assertTrue(self.document_1 in result) + self.assertTrue(self.document_2 in result) + self.assertTrue(self.document_3 in result) diff --git a/mayan/apps/acls/views.py b/mayan/apps/acls/views.py index 143a328a58..2dfaf8fcc8 100644 --- a/mayan/apps/acls/views.py +++ b/mayan/apps/acls/views.py @@ -5,6 +5,7 @@ import logging from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied +from django.core.urlresolvers import reverse from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ @@ -24,11 +25,11 @@ from .permissions import permission_acl_edit, permission_acl_view logger = logging.getLogger(__name__) -def _permission_titles(permission_list): - return ', '.join([unicode(permission) for permission in permission_list]) - - class ACLListView(SingleObjectListView): + @staticmethod + def permission_titles(permission_list): + return ', '.join([unicode(permission) for permission in permission_list]) + def dispatch(self, request, *args, **kwargs): self.content_type = get_object_or_404(ContentType, app_label=self.kwargs['app_label'], model=self.kwargs['model']) @@ -47,32 +48,27 @@ class ACLListView(SingleObjectListView): def get_queryset(self): return AccessControlList.objects.filter(content_type=self.content_type, object_id=self.content_object.pk) - def get_context_data(self, **kwargs): - context = super(ACLListView, self).get_context_data(**kwargs) - context.update( - { - 'hide_object': True, - 'object': self.content_object, - 'title': _('Access control lists for: %s' % self.content_object), - 'extra_columns': [ - { - 'name': _('Role'), - 'attribute': 'role' - }, - { - 'name': _('Permissions'), - 'attribute': encapsulate(lambda x: _permission_titles(x.permissions.all())) - }, - ], - } - ) - - return context + def get_extra_context(self): + return { + 'hide_object': True, + 'object': self.content_object, + 'title': _('Access control lists for: %s' % self.content_object), + 'extra_columns': [ + { + 'name': _('Role'), + 'attribute': 'role' + }, + { + 'name': _('Permissions'), + 'attribute': encapsulate(lambda entry: ACLListView.permission_titles(entry.permissions.all())) + }, + ], + } class ACLCreateView(SingleObjectCreateView): - model = AccessControlList fields = ('role',) + model = AccessControlList def dispatch(self, request, *args, **kwargs): content_type = get_object_or_404(ContentType, app_label=self.kwargs['app_label'], model=self.kwargs['model']) @@ -90,12 +86,15 @@ class ACLCreateView(SingleObjectCreateView): return super(ACLCreateView, self).dispatch(request, *args, **kwargs) def form_valid(self, form): - instance = form.save(commit=False) - instance.content_object = self.content_object - instance.save() + self.instance = form.save(commit=False) + self.instance.content_object = self.content_object + self.instance.save() return super(ACLCreateView, self).form_valid(form) + def get_success_url(self): + return reverse('acls:acl_permissions', args=[self.instance.pk]) + def get_extra_context(self): return { 'object': self.content_object, @@ -105,7 +104,16 @@ class ACLCreateView(SingleObjectCreateView): class ACLDeleteView(SingleObjectDeleteView): model = AccessControlList - object_permission = permission_acl_edit + + def dispatch(self, request, *args, **kwargs): + acl = get_object_or_404(AccessControlList, pk=self.kwargs['pk']) + + try: + Permission.check_permissions(request.user, permissions=(permission_acl_edit,)) + except PermissionDenied: + AccessControlList.objects.check_access(permission_acl_edit, request.user, acl.content_object) + + return super(ACLDeleteView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super(ACLDeleteView, self).get_context_data(**kwargs) @@ -120,45 +128,74 @@ class ACLDeleteView(SingleObjectDeleteView): class ACLPermissionsView(AssignRemoveView): grouped = True - object_permission = permission_acl_edit left_list_title = _('Available permissions') right_list_title = _('Granted permissions') + @staticmethod + def generate_choices(entries): + results = [] + + for namespace, permissions in itertools.groupby(entries, lambda entry: entry.namespace): + permission_options = [(unicode(permission.pk), permission) for permission in permissions] + results.append((PermissionNamespace.get(namespace), permission_options)) + + return results + def add(self, item): permission = get_object_or_404(StoredPermission, pk=item) self.get_object().permissions.add(permission) + def dispatch(self, request, *args, **kwargs): + acl = get_object_or_404(AccessControlList, pk=self.kwargs['pk']) + + try: + Permission.check_permissions(request.user, permissions=(permission_acl_edit,)) + except PermissionDenied: + AccessControlList.objects.check_access(permission_acl_edit, request.user, acl.content_object) + + return super(ACLPermissionsView, self).dispatch(request, *args, **kwargs) + + def get_help_text(self): + if self.get_object().get_inherited_permissions(): + return _('Disabled permissions are inherited from a parent object.') + + return None + def get_object(self): return get_object_or_404(AccessControlList, pk=self.kwargs['pk']) - def left_list(self): - results = [] - for namespace, permissions in itertools.groupby(ModelPermission.get_for_instance(instance=self.get_object().content_object).exclude(id__in=self.get_object().permissions.values_list('pk', flat=True)), lambda entry: entry.namespace): - permission_options = [(unicode(permission.pk), permission) for permission in permissions] - results.append((PermissionNamespace.get(namespace), permission_options)) + def get_available_list(self): + return ModelPermission.get_for_instance(instance=self.get_object().content_object).exclude(id__in=self.get_granted_list().values_list('pk', flat=True)) - return results + def get_disabled_choices(self): + """ + Get permissions from a parent's acls but remove the permissions we + already hold for this object + """ + return map(str, set(self.get_object().get_inherited_permissions().values_list('pk', flat=True)).difference(self.get_object().permissions.values_list('pk', flat=True))) + + def get_extra_context(self): + return { + 'object': self.get_object().content_object, + 'title': _('Role "%(role)s" permission\'s for "%(object)s"') % { + 'role': self.get_object().role, + 'object': self.get_object().content_object, + }, + } + + def get_granted_list(self): + """ + Merge or permissions we hold for this object and the permissions we + hold for this object's parent via another ACL + """ + merged_pks = self.get_object().permissions.values_list('pk', flat=True) | self.get_object().get_inherited_permissions().values_list('pk', flat=True) + return StoredPermission.objects.filter(pk__in=merged_pks) + + def left_list(self): + return ACLPermissionsView.generate_choices(self.get_available_list()) def right_list(self): - results = [] - for namespace, permissions in itertools.groupby(self.get_object().permissions.all(), lambda entry: entry.namespace): - permission_options = [(unicode(permission.pk), permission) for permission in permissions] - results.append((PermissionNamespace.get(namespace), permission_options)) - - return results - - def get_context_data(self, **kwargs): - context = super(ACLPermissionsView, self).get_context_data(**kwargs) - context.update( - { - 'object': self.get_object().content_object, - 'title': _('Role "%(role)s" permission\'s for "%(object)s"') % { - 'role': self.get_object().role, - 'object': self.get_object().content_object, - } - } - ) - return context + return ACLPermissionsView.generate_choices(self.get_granted_list()) def remove(self, item): permission = get_object_or_404(StoredPermission, pk=item) diff --git a/mayan/apps/common/forms.py b/mayan/apps/common/forms.py index 26c4ea2112..c0add795cf 100644 --- a/mayan/apps/common/forms.py +++ b/mayan/apps/common/forms.py @@ -11,7 +11,7 @@ from django.utils.translation import ugettext_lazy as _ from .models import UserLocaleProfile from .utils import return_attrib -from .widgets import DetailSelectMultiple, PlainWidget +from .widgets import DetailSelectMultiple, DisableableSelectWidget, PlainWidget class DetailForm(forms.ModelForm): @@ -57,24 +57,6 @@ class DetailForm(forms.ModelForm): self.fields[field_name].widget.attrs.update({'readonly': 'readonly'}) -class GenericAssignRemoveForm(forms.Form): - def __init__(self, *args, **kwargs): - left_list_qryset = kwargs.pop('left_list_qryset', None) - right_list_qryset = kwargs.pop('right_list_qryset', None) - left_filter = kwargs.pop('left_filter', None) - super(GenericAssignRemoveForm, self).__init__(*args, **kwargs) - if left_filter: - self.fields['left_list'].queryset = left_list_qryset.filter( - *left_filter) - else: - self.fields['left_list'].queryset = left_list_qryset - - self.fields['right_list'].queryset = right_list_qryset - - left_list = forms.ModelMultipleChoiceField(required=False, queryset=None) - right_list = forms.ModelMultipleChoiceField(required=False, queryset=None) - - class ChoiceForm(forms.Form): """ Form to be used in side by side templates used to add or remove @@ -83,12 +65,16 @@ class ChoiceForm(forms.Form): def __init__(self, *args, **kwargs): choices = kwargs.pop('choices', []) label = kwargs.pop('label', _('Selection')) + help_text = kwargs.pop('help_text', None) + disabled_choices = kwargs.pop('disabled_choices', ()) super(ChoiceForm, self).__init__(*args, **kwargs) self.fields['selection'].choices = choices self.fields['selection'].label = label + self.fields['selection'].help_text = help_text + self.fields['selection'].widget.disabled_choices = disabled_choices self.fields['selection'].widget.attrs.update({'size': 14, 'class': 'choice_form'}) - selection = forms.MultipleChoiceField() + selection = forms.MultipleChoiceField(widget=DisableableSelectWidget()) class UserForm_view(DetailForm): diff --git a/mayan/apps/common/views.py b/mayan/apps/common/views.py index 1a825300b8..f1d4b82d77 100644 --- a/mayan/apps/common/views.py +++ b/mayan/apps/common/views.py @@ -33,7 +33,7 @@ from .mixins import ( ) -class AssignRemoveView(ViewPermissionCheckMixin, ObjectPermissionCheckMixin, TemplateView): +class AssignRemoveView(ExtraContextMixin, ViewPermissionCheckMixin, ObjectPermissionCheckMixin, TemplateView): decode_content_type = False extra_context = None grouped = False @@ -75,9 +75,15 @@ class AssignRemoveView(ViewPermissionCheckMixin, ObjectPermissionCheckMixin, Tem # Subclass must override raise NotImplementedError + def get_disabled_choices(self): + return () + + def get_help_text(self): + return self.help_text + def get(self, request, *args, **kwargs): self.unselected_list = ChoiceForm(prefix=self.LEFT_LIST_NAME, choices=self.left_list()) - self.selected_list = ChoiceForm(prefix=self.RIGHT_LIST_NAME, choices=self.right_list()) + self.selected_list = ChoiceForm(prefix=self.RIGHT_LIST_NAME, choices=self.right_list(), disabled_choices=self.get_disabled_choices(), help_text=self.get_help_text()) return self.render_to_response(self.get_context_data()) def process_form(self, prefix, items_function, action_function): diff --git a/mayan/apps/documents/apps.py b/mayan/apps/documents/apps.py index fbf4df12b3..26d62cd113 100644 --- a/mayan/apps/documents/apps.py +++ b/mayan/apps/documents/apps.py @@ -98,6 +98,14 @@ class DocumentsApp(MayanAppConfig): ) ) + ModelPermission.register_proxy( + source=Document, model=DocumentType, + ) + + ModelPermission.register_inheritance( + model=Document, related='document_type', + ) + SourceColumn(source=Document, label=_('Thumbnail'), attribute=encapsulate(lambda document: document_thumbnail(document, gallery_name='documents:document_list', title=getattr(document, 'label', None), size=setting_thumbnail_size.value))) SourceColumn(source=Document, label=_('Type'), attribute='document_type') SourceColumn(source=DeletedDocument, label=_('Type'), attribute='document_type') @@ -124,7 +132,7 @@ class DocumentsApp(MayanAppConfig): menu_tools.bind_links(links=[link_clear_image_cache]) # Document type links - menu_object.bind_links(links=[link_document_type_edit, link_document_type_filename_list, link_document_type_delete], sources=[DocumentType]) + menu_object.bind_links(links=[link_document_type_edit, link_document_type_filename_list, link_acl_list, link_document_type_delete], sources=[DocumentType]) menu_object.bind_links(links=[link_document_type_filename_edit, link_document_type_filename_delete], sources=[DocumentTypeFilename]) menu_secondary.bind_links(links=[link_document_type_list, link_document_type_create], sources=[DocumentType, 'documents:document_type_create', 'documents:document_type_list']) menu_sidebar.bind_links(links=[link_document_type_filename_create], sources=[DocumentTypeFilename, 'documents:document_type_filename_list', 'documents:document_type_filename_create'])