diff --git a/djangoldp/permissions.py b/djangoldp/permissions.py index 468915768327c042ba3c777798056ffec7c1f23a..05e8fc1f849df4b5e6e5229d58415d7517cb548b 100644 --- a/djangoldp/permissions.py +++ b/djangoldp/permissions.py @@ -1,5 +1,6 @@ from copy import copy from django.conf import settings +from django.http import Http404 from rest_framework.permissions import BasePermission, DjangoObjectPermissions, OR, AND from rest_framework.filters import BaseFilterBackend from rest_framework_guardian.filters import ObjectPermissionsFilter @@ -8,20 +9,22 @@ from djangoldp.utils import is_anonymous_user, is_authenticated_user DEFAULT_DJANGOLDP_PERMISSIONS = {'view', 'add', 'change', 'delete', 'control'} -def join_filter_backends(*permissions, model, union=False): +def join_filter_backends(*permissions_or_filters:BaseFilterBackend|BasePermission, model:object, union:bool=False) -> BaseFilterBackend: '''Creates a new Filter backend by joining a list of existing backends. It chains the filterings or joins them, depending on the argument union''' backends = [] - for permission in permissions: - if hasattr(permission, 'get_filter_backend'): - backends.append(permission.get_filter_backend(model)) + for permission_or_filter in permissions_or_filters: + if hasattr(permission_or_filter, 'get_filter_backend'): + backends.append(permission_or_filter.get_filter_backend(model)) + elif isinstance(permission_or_filter, type) and issubclass(permission_or_filter, BaseFilterBackend): + backends.append(permission_or_filter) class JointFilterBackend(BaseFilterBackend): def __init__(self) -> None: self.filters = [] for backend in backends: if backend: self.filters.append(backend()) - def filter_queryset(self, request, queryset, view): + def filter_queryset(self, request:object, queryset:object, view:object) -> object: if union: result = queryset.none() #starts with empty for union else: @@ -92,7 +95,7 @@ class LDPBasePermission(BasePermission): return request.method in self.get_allowed_methods() def has_object_permission(self, request, view, obj=None): '''checks if the access to the object is allowed,''' - return True + return self.has_permission(request, view) def get_permissions(self, user, model, obj=None): '''returns the permissions the user has on a given model or on a given object''' return self.permissions @@ -160,9 +163,9 @@ class PublicPermission(LDPBasePermission): filter_backend = PublicFilterBackend permissions = {'view', 'add'} def has_object_permission(self, request, view, obj=None): - assert hasattr(request.model._meta, 'public_field'), \ - f'Model {request.model} has PublicPermission applied without "public_field" defined' - public_field = request.model._meta.public_field + assert hasattr(view.model._meta, 'public_field'), \ + f'Model {view.model} has PublicPermission applied without "public_field" defined' + public_field = view.model._meta.public_field if getattr(obj, public_field, False): return super().has_object_permission(request, view, obj) @@ -172,25 +175,28 @@ class PublicPermission(LDPBasePermission): class InheritPermissions(LDPBasePermission): """Gets the permissions from a related objects""" @classmethod - def get_parent_model(cls, model): + def get_parent_fields(cls, model: object) -> list: '''checks that the model is adequately configured and returns the associated model''' assert hasattr(model._meta, 'inherit_permissions'), \ f'Model {model} has InheritPermissions applied without "inherit_permissions" defined' - parent_field = model._meta.inherit_permissions - parent_model = model._meta.get_field(parent_field).related_model + return model._meta.inherit_permissions + + @classmethod + def get_parent_model(cls, model:object, field_name:str) -> object: + parent_model = model._meta.get_field(field_name).related_model assert hasattr(parent_model._meta, 'permission_classes'), \ f'Related model {parent_model} has no "permission_classes" defined' return parent_model - def get_parent_object(self, obj): + def get_parent_object(self, obj:object, field_name:str) -> object|None: '''gets the parent object''' if obj: - return getattr(obj, obj._meta.inherit_permissions) + return getattr(obj, field_name) return None @classmethod - def clone_with_model(self, request, view, model): + def clone_with_model(self, request:object, view:object, model:object) -> tuple: '''changes the model on the argument, so that they can be called on the parent model''' request = copy(request._request) request.model = model @@ -198,12 +204,11 @@ class InheritPermissions(LDPBasePermission): view.queryset = None #to make sure the model is taken into account view.model = model return request, view - + @classmethod - def get_filter_backend(cls, model): + def generate_filter_backend(cls, parent:object, field_name:str) -> BaseFilterBackend: '''returns a new Filter backend that applies all filters of the parent model''' - parent = cls.get_parent_model(model) - filter_arg = f'{model._meta.inherit_permissions}__in' + filter_arg = f'{field_name}__in' backends = {perm().get_filter_backend(parent) for perm in parent._meta.permission_classes} class InheritFilterBackend(BaseFilterBackend): @@ -212,7 +217,7 @@ class InheritPermissions(LDPBasePermission): for backend in backends: if backend: self.filters.append(backend()) - def filter_queryset(self, request, queryset, view): + def filter_queryset(self, request:object, queryset:object, view:object) -> object: request, view = InheritPermissions.clone_with_model(request, view, parent) for filter in self.filters: allowed_parents = filter.filter_queryset(request, parent.objects.all(), view) @@ -220,19 +225,41 @@ class InheritPermissions(LDPBasePermission): return queryset return InheritFilterBackend + + @classmethod + def get_filter_backend(cls, model:object) -> BaseFilterBackend: + '''Returns a union filter backend of all filter backends of parents''' + backends = [cls.generate_filter_backend(cls.get_parent_model(model, field), field) for field in cls.get_parent_fields(model)] + return join_filter_backends(*backends, model=model, union=True) - def has_permission(self, request, view): - model = InheritPermissions.get_parent_model(view.model) - request, view = InheritPermissions.clone_with_model(request, view, model) - return all([perm().has_permission(request, view) for perm in model._meta.permission_classes]) + def has_permission(self, request:object, view:object) -> bool: + '''Returns True if at least one inheriting link has permission''' + for field in InheritPermissions.get_parent_fields(view.model): + model = InheritPermissions.get_parent_model(view.model, field) + request, view = InheritPermissions.clone_with_model(request, view, model) + if all([perm().has_permission(request, view) for perm in model._meta.permission_classes]): + return True + return False - def has_object_permissions(self, request, view, obj): - model = InheritPermissions.get_parent_model(view.model) - request, view = InheritPermissions.clone_with_model(request, view, model) - obj = self.get_parent_object(obj) - return all([perm().has_object_permissions(request, view, obj) for perm in model._meta.permission_classes]) + def has_object_permission(self, request:object, view:object, obj:object) -> bool: + '''Returns True if at least one inheriting link has permission''' + for field in InheritPermissions.get_parent_fields(view.model): + model = InheritPermissions.get_parent_model(view.model, field) + parent_request, parent_view = InheritPermissions.clone_with_model(request, view, model) + parent_object = self.get_parent_object(obj, field) + try: + if all([perm().has_object_permission(parent_request, parent_view, parent_object) for perm in model._meta.permission_classes]): + return True + except Http404: + #keep trying + pass + return False - def get_permissions(self, user, model, obj=None): - model = InheritPermissions.get_parent_model(model) - obj = self.get_parent_object(obj) - return set.intersection(*[perm().get_permissions(user, model, obj) for perm in model._meta.permission_classes]) \ No newline at end of file + def get_permissions(self, user:object, model:object, obj:object=None) -> set: + '''returns a union of all inheriting linked permissions''' + perms = set() + for field in InheritPermissions.get_parent_fields(model): + parent_model = InheritPermissions.get_parent_model(model, field) + obj = self.get_parent_object(obj, field) + perms.union(set.intersection(*[perm().get_permissions(user, parent_model, obj) for perm in parent_model._meta.permission_classes])) + return perms \ No newline at end of file diff --git a/djangoldp/tests/models.py b/djangoldp/tests/models.py index 6a6548ada34087dee709b086856df5122cca1679..8f4d4ef28f42e1e13e87e4fe246f729a81b7efa6 100644 --- a/djangoldp/tests/models.py +++ b/djangoldp/tests/models.py @@ -230,7 +230,7 @@ class ReadAndCreatePost(Model): class Meta(Model.Meta): ordering = ['pk'] permission_classes = [ReadAndCreate] - + class ANDPermissionsDummy(Model): title = models.CharField(max_length=255) class Meta(Model.Meta): @@ -295,7 +295,16 @@ class RestrictedResource(Model): class Meta(Model.Meta): ordering = ['pk'] permission_classes = [InheritPermissions] - inherit_permissions = 'circle' + inherit_permissions = ['circle'] + +class DoubleInheritModel(Model): + content = models.CharField(max_length=255, blank=True) + ro_ancestor = models.ForeignKey(ReadOnlyPost, on_delete=models.CASCADE, null=True, blank=True) + circle = models.ForeignKey(RestrictedCircle, on_delete=models.CASCADE, null=True, blank=True) + class Meta(Model.Meta): + ordering = ['pk'] + permission_classes = [InheritPermissions] + inherit_permissions = ['circle', 'ro_ancestor'] class Space(Model): name = models.CharField(max_length=255, blank=True) diff --git a/djangoldp/tests/tests_permissions.py b/djangoldp/tests/tests_permissions.py index 76d0d6d920f46d9e357d9aab59f140bfab7de6f5..65acd6ef0253b045c56310de91e6f9e897e778db 100644 --- a/djangoldp/tests/tests_permissions.py +++ b/djangoldp/tests/tests_permissions.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from guardian.models import GroupObjectPermission from rest_framework.test import APIRequestFactory, APIClient, APITestCase -from djangoldp.tests.models import AnonymousReadOnlyPost, AuthenticatedOnlyPost, ReadOnlyPost, \ +from djangoldp.tests.models import AnonymousReadOnlyPost, AuthenticatedOnlyPost, ReadOnlyPost, DoubleInheritModel, \ ReadAndCreatePost, OwnedResource, RestrictedCircle, RestrictedResource, ANDPermissionsDummy, ORPermissionsDummy class TestPermissions(APITestCase): @@ -112,7 +112,7 @@ class TestPermissions(APITestCase): self.assertEqual(set(perms.values_list('permission__codename', flat=True)), {f'{perm}_{obj._meta.model_name}' for perm in required_perms}) - def create_cirlces(self): + def create_circles(self): self.authenticate() self.user.user_permissions.add(Permission.objects.get(codename='view_restrictedcircle')) them = get_user_model().objects.create_user(username='them', email='them@user.com', password='itstheirsecret') @@ -122,7 +122,7 @@ class TestPermissions(APITestCase): return mine, theirs, noones def test_role_permissions(self): - mine, theirs, noones = self.create_cirlces() + mine, theirs, noones = self.create_circles() self.assertIn(self.user, mine.members.user_set.all()) self.assertIn(self.user, mine.admins.user_set.all()) self.assertNotIn(self.user, theirs.members.user_set.all()) @@ -136,13 +136,28 @@ class TestPermissions(APITestCase): self.check_permissions(mine, mine.admins, RestrictedCircle._meta.permission_roles['admins']['perms']) def test_inherit_permissions(self): - mine, theirs, noones = self.create_cirlces() + mine, theirs, noones = self.create_circles() myresource = RestrictedResource.objects.create(content="mine", circle=mine) - RestrictedResource.objects.create(content="theirs", circle=theirs) - RestrictedResource.objects.create(content="noones", circle=noones) + their_resource = RestrictedResource.objects.create(content="theirs", circle=theirs) + noones_resource = RestrictedResource.objects.create(content="noones", circle=noones) self.check_can_view('/restrictedresources/', [myresource.urlid]) self.check_can_change(myresource.urlid) + self.check_can_change(their_resource.urlid, 404) + self.check_can_change(noones_resource.urlid, 404) + + + def test_inherit_several_permissions(self): + mine, theirs, noones = self.create_circles() + ro_resource = ReadOnlyPost.objects.create(content="read only") + myresource = DoubleInheritModel.objects.create(content="mine", circle=mine, ro_ancestor=None) + some = DoubleInheritModel.objects.create(content="some", circle=theirs, ro_ancestor=ro_resource) + other = DoubleInheritModel.objects.create(content="other", circle=noones, ro_ancestor=None) + + self.check_can_view('/doubleinheritmodels/', [myresource.urlid, some.urlid]) + self.check_can_change(myresource.urlid) + self.check_can_change(some.urlid, 403) + self.check_can_change(other.urlid, 404) def test_and_permissions(self): diff --git a/djangoldp/utils.py b/djangoldp/utils.py index 83075fd842ce28b8e79068374f49397ab60de3e7..a32ac6157cbea15e2d412d5449a74ef6c139ad21 100644 --- a/djangoldp/utils.py +++ b/djangoldp/utils.py @@ -4,11 +4,11 @@ from guardian.utils import get_anonymous_user # convenience function returns True if user is anonymous def is_anonymous_user(user): - return user.is_anonymous or (getattr(settings, 'ANONYMOUS_USER_NAME', True) is not None and - user == get_anonymous_user()) + anonymous_username = getattr(settings, 'ANONYMOUS_USER_NAME', None) + return user.is_anonymous or user.username == anonymous_username # convenience function returns True if user is authenticated def is_authenticated_user(user): - return user.is_authenticated and (getattr(settings, 'ANONYMOUS_USER_NAME', True) is None or - user != get_anonymous_user()) + anonymous_username = getattr(settings, 'ANONYMOUS_USER_NAME', None) + return user.is_authenticated and user.username != anonymous_username diff --git a/djangoldp/views.py b/djangoldp/views.py index 452a68629e76a5042601ad1f9cb5a255f7646565..027680003d8b719568d6e0e29963bec95c1edee0 100644 --- a/djangoldp/views.py +++ b/djangoldp/views.py @@ -393,6 +393,9 @@ class LDPViewSetGenerator(ModelViewSet): if kwargs.get('model_prefix'): model_name = '{}-{}'.format(kwargs['model_prefix'], model_name) detail_expr = cls.get_detail_expr(**kwargs) + # Gets permissions on the model if not explicitely passed to the view + if not 'permission_classes' in kwargs and hasattr(kwargs['model']._meta, 'permission_classes'): + kwargs['permission_classes'] = kwargs['model']._meta.permission_classes urls = [ path('', cls.as_view(cls.list_actions, **kwargs), name='{}-list'.format(model_name)), diff --git a/docs/create_model.md b/docs/create_model.md index f0a1fa8274049706b7fd0011e93717d8264800d8..908b786962e2dae6b7b97b562ff670cd5240a854 100644 --- a/docs/create_model.md +++ b/docs/create_model.md @@ -323,7 +323,7 @@ DjangoLDP comes with a set of permission classes that you can use for standard b * LDDPermissions: Give access based on the permissions in the database. For container requests (list and create), based on model level permissions. For all others, based on object level permissions. This permission class is associated with a filter that only renders objects on which the user has access. * PublicPermission: Give access based on a public flag on the object. This class must be used in conjonction with the Meta option `public_field`. This permission class is associated with a filter that only render objects that have the public flag set. * OwnerPermissions: Give access based on the owner of the object. This class must be used in conjonction with the Meta option `owner_field` or `owner_urlid_field`. This permission class is associated with a filter that only render objects of which the user is owner. - * InheritPermissions: Give access based on the permissions on a related model. This class must be used in conjonction with the Meta option `inherit_permission`, which value must be the name of the `ForeignKey` or `OneToOneField` pointing to the object bearing the permission classes. It also applies filter based on the related model. + * InheritPermissions: Give access based on the permissions on a related model. This class must be used in conjonction with the Meta option `inherit_permission`, which value must be a list of names of the `ForeignKey` or `OneToOneField` pointing to the objects bearing the permission classes. It also applies filter based on the related model. If several fields are given, at least one must give permission for the permission to be granted. Permission classes can be chained together in a list, or through the | and & operators. Chaining in a list is equivalent to using the & operator. @@ -333,7 +333,7 @@ class MyModel(models.Model): related = models.ForeignKey(SomeOtherModel) class Meta: permission_classes = [InheritPermissions, AuthenticatedOnly&(ReadOnly|OwnerPermissions|ACLPermissions)] - inherit_permissions = 'related + inherit_permissions = ['related'] owner_field = 'author_user' auto_author_field = 'profile' ```