Add permission inheritance by parent object. Add ACLs app model tests.

This commit is contained in:
Roberto Rosario
2015-07-10 01:40:21 -04:00
parent 441eae28bc
commit bc3eed143c
9 changed files with 337 additions and 90 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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'])