From 057f42fdb67195d5cd434924bb083074eaf35a99 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 18 Mar 2020 14:42:35 +0100 Subject: [PATCH] Handle permissions (and it seems working!) --- apps/activity/api/views.py | 9 ++-- apps/api/urls.py | 12 +++-- apps/api/viewsets.py | 26 +++++++++++ apps/logs/api/views.py | 4 +- apps/member/api/views.py | 10 ++-- apps/member/backends.py | 57 ++++++++++++++++++++--- apps/member/views.py | 1 - apps/note/api/serializers.py | 6 +++ apps/note/api/views.py | 24 +++++----- apps/permission/__init__.py | 4 ++ apps/permission/api/__init__.py | 0 apps/permission/api/serializers.py | 17 +++++++ apps/permission/api/urls.py | 11 +++++ apps/permission/api/views.py | 20 ++++++++ apps/permission/apps.py | 7 +++ apps/permission/models.py | 70 +++++++++++++++++++--------- apps/permission/permissions.py | 58 +++++++++++++++++++++++ apps/permission/signals.py | 75 ++++++++++++++++++++++++++++++ note_kfet/settings/base.py | 3 +- 19 files changed, 357 insertions(+), 57 deletions(-) create mode 100644 apps/api/viewsets.py create mode 100644 apps/permission/api/__init__.py create mode 100644 apps/permission/api/serializers.py create mode 100644 apps/permission/api/urls.py create mode 100644 apps/permission/api/views.py create mode 100644 apps/permission/permissions.py create mode 100644 apps/permission/signals.py diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 4ee2194d..651560fd 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -1,14 +1,15 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later + from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets from rest_framework.filters import SearchFilter +from api.viewsets import ReadProtectedModelViewSet from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer from ..models import ActivityType, Activity, Guest -class ActivityTypeViewSet(viewsets.ModelViewSet): +class ActivityTypeViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer, @@ -20,7 +21,7 @@ class ActivityTypeViewSet(viewsets.ModelViewSet): filterset_fields = ['name', 'can_invite', ] -class ActivityViewSet(viewsets.ModelViewSet): +class ActivityViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer, @@ -32,7 +33,7 @@ class ActivityViewSet(viewsets.ModelViewSet): filterset_fields = ['name', 'description', 'activity_type', ] -class GuestViewSet(viewsets.ModelViewSet): +class GuestViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer, diff --git a/apps/api/urls.py b/apps/api/urls.py index 95ed5f99..40e6c572 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,12 +5,16 @@ from django.conf.urls import url, include from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import routers, serializers, viewsets +from rest_framework import routers, serializers from rest_framework.filters import SearchFilter +from rest_framework.viewsets import ReadOnlyModelViewSet + from activity.api.urls import register_activity_urls +from api.viewsets import ReadProtectedModelViewSet from member.api.urls import register_members_urls from note.api.urls import register_note_urls from logs.api.urls import register_logs_urls +from permission.api.urls import register_permission_urls class UserSerializer(serializers.ModelSerializer): @@ -39,7 +43,7 @@ class ContentTypeSerializer(serializers.ModelSerializer): fields = '__all__' -class UserViewSet(viewsets.ModelViewSet): +class UserViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, @@ -52,7 +56,8 @@ class UserViewSet(viewsets.ModelViewSet): search_fields = ['$username', '$first_name', '$last_name', ] -class ContentTypeViewSet(viewsets.ReadOnlyModelViewSet): +# This ViewSet is the only one that is accessible from all authenticated users! +class ContentTypeViewSet(ReadOnlyModelViewSet): """ REST API View set. The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer, @@ -70,6 +75,7 @@ router.register('user', UserViewSet) register_members_urls(router, 'members') register_activity_urls(router, 'activity') register_note_urls(router, 'note') +register_permission_urls(router, 'permission') register_logs_urls(router, 'logs') app_name = 'api' diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py new file mode 100644 index 00000000..cb32b09e --- /dev/null +++ b/apps/api/viewsets.py @@ -0,0 +1,26 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.contenttypes.models import ContentType +from member.backends import PermissionBackend +from rest_framework import viewsets + + +class ReadProtectedModelViewSet(viewsets.ModelViewSet): + """ + Protect a ModelViewSet by filtering the objects that the user cannot see. + """ + + def get_queryset(self): + model = ContentType.objects.get_for_model(self.serializer_class.Meta.model) + return super().get_queryset().filter(PermissionBackend().filter_queryset(self.request.user, model, "view")) + + +class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet): + """ + Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see. + """ + + def get_queryset(self): + model = ContentType.objects.get_for_model(self.serializer_class.Meta.model) + return super().get_queryset().filter(PermissionBackend().filter_queryset(self.request.user, model, "view")) diff --git a/apps/logs/api/views.py b/apps/logs/api/views.py index 2c47b7a2..6bd4f721 100644 --- a/apps/logs/api/views.py +++ b/apps/logs/api/views.py @@ -2,14 +2,14 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets from rest_framework.filters import OrderingFilter +from api.viewsets import ReadOnlyProtectedModelViewSet from .serializers import ChangelogSerializer from ..models import Changelog -class ChangelogViewSet(viewsets.ReadOnlyModelViewSet): +class ChangelogViewSet(ReadOnlyProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, diff --git a/apps/member/api/views.py b/apps/member/api/views.py index c85df903..b4715cae 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -1,14 +1,14 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from rest_framework import viewsets from rest_framework.filters import SearchFilter +from api.viewsets import ReadProtectedModelViewSet from .serializers import ProfileSerializer, ClubSerializer, RoleSerializer, MembershipSerializer from ..models import Profile, Club, Role, Membership -class ProfileViewSet(viewsets.ModelViewSet): +class ProfileViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer, @@ -18,7 +18,7 @@ class ProfileViewSet(viewsets.ModelViewSet): serializer_class = ProfileSerializer -class ClubViewSet(viewsets.ModelViewSet): +class ClubViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer, @@ -30,7 +30,7 @@ class ClubViewSet(viewsets.ModelViewSet): search_fields = ['$name', ] -class RoleViewSet(viewsets.ModelViewSet): +class RoleViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Role` objects, serialize it to JSON with the given serializer, @@ -42,7 +42,7 @@ class RoleViewSet(viewsets.ModelViewSet): search_fields = ['$name', ] -class MembershipViewSet(viewsets.ModelViewSet): +class MembershipViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer, diff --git a/apps/member/backends.py b/apps/member/backends.py index db227cdb..3fdbd8d1 100644 --- a/apps/member/backends.py +++ b/apps/member/backends.py @@ -1,7 +1,12 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from member.models import Club, Membership, RolePermissions +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied +from django.db.models import Q, F + +from note.models import Note, NoteUser, NoteClub, NoteSpecial +from .models import Membership, RolePermissions, Club from django.contrib.auth.backends import ModelBackend @@ -14,21 +19,61 @@ class PermissionBackend(ModelBackend): for membership in Membership.objects.filter(user=user).all(): if not membership.valid() or membership.roles is None: continue + for role_permissions in RolePermissions.objects.filter(role=membership.roles).all(): for permission in role_permissions.permissions.all(): - permission = permission.about(user=user, club=membership.club) + permission = permission.about( + user=user, + club=membership.club, + User=User, + Club=Club, + Membership=Membership, + Note=Note, + NoteUser=NoteUser, + NoteClub=NoteClub, + NoteSpecial=NoteSpecial, + F=F, + Q=Q + ) yield permission + def filter_queryset(self, user, model, type, field=None): + """ + Filter a queryset by considering the permissions of a given user. + :param user: The owner of the permissions that are fetched + :param model: The concerned model of the queryset + :param type: The type of modification (view, add, change, delete) + :param field: The field of the model to test, if concerned + :return: A query that corresponds to the filter to give to a queryset + """ + + if user.is_superuser: + # Superusers have all rights + return Q() + + # Never satisfied + query = Q(pk=-1) + for perm in self.permissions(user): + if field and field != perm.field: + continue + if perm.model != model or perm.type != type: + continue + query = query | perm.query + return query + def has_perm(self, user_obj, perm, obj=None): if user_obj.is_superuser: return True if obj is None: - return False - perm = perm.split('_', 3) - perm_type = perm[1] + return True + + perm = perm.split('.')[-1].split('_', 2) + perm_type = perm[0] perm_field = perm[2] if len(perm) == 3 else None - return any(permission.applies(obj, perm_type, perm_field) for permission in self.permissions(user_obj)) + if any(permission.applies(obj, perm_type, perm_field) for permission in self.permissions(user_obj)): + return True + return False def has_module_perms(self, user_obj, app_label): return False diff --git a/apps/member/views.py b/apps/member/views.py index dacfde33..2213f37d 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -203,7 +203,6 @@ class DeleteAliasView(LoginRequiredMixin, DeleteView): return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): - print(self.request) return reverse_lazy('member:user_alias', kwargs={'pk': self.object.note.user.pk}) def get(self, request, *args, **kwargs): diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index 85f500ed..02311de1 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -88,6 +88,9 @@ class NotePolymorphicSerializer(PolymorphicSerializer): NoteSpecial: NoteSpecialSerializer } + class Meta: + model = Note + class TemplateCategorySerializer(serializers.ModelSerializer): """ @@ -162,3 +165,6 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer): MembershipTransaction: MembershipTransactionSerializer, SpecialTransaction: SpecialTransactionSerializer, } + + class Meta: + model = Transaction diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 29c79bd8..6a3bb41e 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -3,9 +3,9 @@ from django.db.models import Q from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import viewsets from rest_framework.filters import OrderingFilter, SearchFilter +from api.viewsets import ReadProtectedModelViewSet from .serializers import NoteSerializer, NotePolymorphicSerializer, NoteClubSerializer, NoteSpecialSerializer, \ NoteUserSerializer, AliasSerializer, \ TemplateCategorySerializer, TransactionTemplateSerializer, TransactionPolymorphicSerializer @@ -13,7 +13,7 @@ from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory -class NoteViewSet(viewsets.ModelViewSet): +class NoteViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Note` objects, serialize it to JSON with the given serializer, @@ -23,7 +23,7 @@ class NoteViewSet(viewsets.ModelViewSet): serializer_class = NoteSerializer -class NoteClubViewSet(viewsets.ModelViewSet): +class NoteClubViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `NoteClub` objects, serialize it to JSON with the given serializer, @@ -33,7 +33,7 @@ class NoteClubViewSet(viewsets.ModelViewSet): serializer_class = NoteClubSerializer -class NoteSpecialViewSet(viewsets.ModelViewSet): +class NoteSpecialViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `NoteSpecial` objects, serialize it to JSON with the given serializer, @@ -43,7 +43,7 @@ class NoteSpecialViewSet(viewsets.ModelViewSet): serializer_class = NoteSpecialSerializer -class NoteUserViewSet(viewsets.ModelViewSet): +class NoteUserViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `NoteUser` objects, serialize it to JSON with the given serializer, @@ -53,7 +53,7 @@ class NoteUserViewSet(viewsets.ModelViewSet): serializer_class = NoteUserSerializer -class NotePolymorphicViewSet(viewsets.ModelViewSet): +class NotePolymorphicViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer, @@ -70,7 +70,7 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): Parse query and apply filters. :return: The filtered set of requested notes """ - queryset = Note.objects.all() + queryset = super().get_queryset() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( @@ -92,7 +92,7 @@ class NotePolymorphicViewSet(viewsets.ModelViewSet): return queryset.distinct() -class AliasViewSet(viewsets.ModelViewSet): +class AliasViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, @@ -110,7 +110,7 @@ class AliasViewSet(viewsets.ModelViewSet): :return: The filtered set of requested aliases """ - queryset = Alias.objects.all() + queryset = super().get_queryset() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( @@ -138,7 +138,7 @@ class AliasViewSet(viewsets.ModelViewSet): return queryset -class TemplateCategoryViewSet(viewsets.ModelViewSet): +class TemplateCategoryViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer, @@ -150,7 +150,7 @@ class TemplateCategoryViewSet(viewsets.ModelViewSet): search_fields = ['$name', ] -class TransactionTemplateViewSet(viewsets.ModelViewSet): +class TransactionTemplateViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, @@ -162,7 +162,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): filterset_fields = ['name', 'amount', 'display', 'category', ] -class TransactionViewSet(viewsets.ModelViewSet): +class TransactionViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer, diff --git a/apps/permission/__init__.py b/apps/permission/__init__.py index e69de29b..4e3eb6bc 100644 --- a/apps/permission/__init__.py +++ b/apps/permission/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +default_app_config = 'permission.apps.PermissionConfig' diff --git a/apps/permission/api/__init__.py b/apps/permission/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/api/serializers.py b/apps/permission/api/serializers.py new file mode 100644 index 00000000..0a52f4fe --- /dev/null +++ b/apps/permission/api/serializers.py @@ -0,0 +1,17 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework import serializers + +from ..models import Permission + + +class PermissionSerializer(serializers.ModelSerializer): + """ + REST API Serializer for Permission types. + The djangorestframework plugin will analyse the model `Permission` and parse all fields in the API. + """ + + class Meta: + model = Permission + fields = '__all__' diff --git a/apps/permission/api/urls.py b/apps/permission/api/urls.py new file mode 100644 index 00000000..d50344ea --- /dev/null +++ b/apps/permission/api/urls.py @@ -0,0 +1,11 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from .views import PermissionViewSet + + +def register_permission_urls(router, path): + """ + Configure router for permission REST API. + """ + router.register(path, PermissionViewSet) diff --git a/apps/permission/api/views.py b/apps/permission/api/views.py new file mode 100644 index 00000000..6087c83e --- /dev/null +++ b/apps/permission/api/views.py @@ -0,0 +1,20 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django_filters.rest_framework import DjangoFilterBackend + +from api.viewsets import ReadOnlyProtectedModelViewSet +from .serializers import PermissionSerializer +from ..models import Permission + + +class PermissionViewSet(ReadOnlyProtectedModelViewSet): + """ + REST API View set. + The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer, + then render it on /api/logs/ + """ + queryset = Permission.objects.all() + serializer_class = PermissionSerializer + filter_backends = [DjangoFilterBackend] + filterset_fields = ['model', 'type', ] diff --git a/apps/permission/apps.py b/apps/permission/apps.py index c9c912a5..c0caa41b 100644 --- a/apps/permission/apps.py +++ b/apps/permission/apps.py @@ -2,7 +2,14 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.apps import AppConfig +from django.db.models.signals import pre_save, pre_delete class PermissionConfig(AppConfig): name = 'permission' + + def ready(self): + # noinspection PyUnresolvedReferences + from . import signals + pre_save.connect(signals.pre_save_object) + pre_delete.connect(signals.pre_delete_object) diff --git a/apps/permission/models.py b/apps/permission/models.py index 9584f59f..b90fcfb9 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -27,12 +27,13 @@ class InstancedPermission: """ if self.type == 'add': if permission_type == self.type: - return obj in self.model.modelclass().objects.get(self.query) + return self.query(obj) + if ContentType.objects.get_for_model(obj) != self.model: # The permission does not apply to the model return False if permission_type == self.type: - if field_name and field_name != self.field: + if self.field and field_name != self.field: return False return obj in self.model.model_class().objects.filter(self.query).all() else: @@ -91,6 +92,7 @@ class Permission(models.Model): unique_together = ('model', 'query', 'type', 'field') def clean(self): + self.query = json.dumps(json.loads(self.query)) if self.field and self.type not in {'view', 'change'}: raise ValidationError(_("Specifying field applies only to view and change permission types.")) @@ -101,21 +103,45 @@ class Permission(models.Model): @staticmethod def compute_f(oper, **kwargs): if isinstance(oper, list): - if len(oper) == 1: - return kwargs[oper[0]].pk - elif len(oper) >= 2: - if oper[0] == 'ADD': - return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) - elif oper[0] == 'SUB': - return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs) - elif oper[0] == 'MUL': - return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) - elif oper[0] == 'F': - return F(oper[1]) + if oper[0] == 'ADD': + return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'SUB': + return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs) + elif oper[0] == 'MUL': + return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'F': + return F(oper[1]) + else: + field = kwargs[oper[0]] + for i in range(1, len(oper)): + field = getattr(field, oper[i]) + return field else: return oper - # TODO: find a better way to crash here - raise Exception("F is wrong") + + @staticmethod + def compute_param(value, **kwargs): + if not isinstance(value, list): + return value + + field = kwargs[value[0]] + for i in range(1, len(value)): + if isinstance(value[i], list): + field = getattr(field, value[i][0]) + params = [] + call_kwargs = {} + for j in range(1, len(value[i])): + param = Permission.compute_param(value[i][j], **kwargs) + if isinstance(param, dict): + for key in param: + val = Permission.compute_param(param[key], **kwargs) + call_kwargs[key] = val + else: + params.append(param) + field = field(*params, **call_kwargs) + else: + field = getattr(field, value[i]) + return field def _about(self, query, **kwargs): if self.type == 'add': @@ -124,8 +150,8 @@ class Permission(models.Model): if len(query) == 0: # The query is either [] or {} and # applies to all objects of the model - # to represent this we return None - return None + # to represent this we return a trivial request + return Q(pk=F("pk")) if isinstance(query, list): if query[0] == 'AND': return functools.reduce(operator.and_, [self._about(query, **kwargs) for query in query[1:]]) @@ -138,11 +164,11 @@ class Permission(models.Model): for key in query: value = query[key] if isinstance(value, list): - # It is a parameter we query its primary key - q_kwargs[key] = kwargs[value[0]].pk + # It is a parameter we query its return value + q_kwargs[key] = Permission.compute_param(value, **kwargs) elif isinstance(value, dict): # It is an F object - q_kwargs[key] = Permission.compute_f(query['F'], **kwargs) + q_kwargs[key] = Permission.compute_f(value['F'], **kwargs) else: q_kwargs[key] = value return Q(**q_kwargs) @@ -167,7 +193,7 @@ class Permission(models.Model): value = query[key] if isinstance(value, list): # It is a parameter we query its primary key - q_kwargs[key] = kwargs[value[0]].pk + q_kwargs[key] = Permission.compute_param(value, **kwargs) elif isinstance(value, dict): # It is an F object q_kwargs[key] = Permission.compute_f(query['F'], **kwargs) @@ -176,7 +202,7 @@ class Permission(models.Model): def func(obj): nonlocal q_kwargs for arg in q_kwargs: - if getattr(obj, arg) != q_kwargs(arg): + if getattr(obj, arg) != q_kwargs[arg]: return False return True return func diff --git a/apps/permission/permissions.py b/apps/permission/permissions.py new file mode 100644 index 00000000..1cbae474 --- /dev/null +++ b/apps/permission/permissions.py @@ -0,0 +1,58 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from rest_framework.permissions import DjangoObjectPermissions + +SAFE_METHODS = ('HEAD', 'OPTIONS', ) + + +class StrongDjangoObjectPermissions(DjangoObjectPermissions): + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': [], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + + def get_required_object_permissions(self, method, model_cls): + kwargs = { + 'app_label': model_cls._meta.app_label, + 'model_name': model_cls._meta.model_name + } + + if method not in self.perms_map: + from rest_framework import exceptions + raise exceptions.MethodNotAllowed(method) + + return [perm % kwargs for perm in self.perms_map[method]] + + def has_object_permission(self, request, view, obj): + # authentication checks have already executed via has_permission + queryset = self._queryset(view) + model_cls = queryset.model + user = request.user + + perms = self.get_required_object_permissions(request.method, model_cls) + + if not user.has_perms(perms, obj): + # If the user does not have permissions we need to determine if + # they have read permissions to see 403, or not, and simply see + # a 404 response. + from django.http import Http404 + + if request.method in SAFE_METHODS: + # Read permissions already checked and failed, no need + # to make another lookup. + raise Http404 + + read_perms = self.get_required_object_permissions('GET', model_cls) + if not user.has_perms(read_perms, obj): + raise Http404 + + # Has read permissions. + return False + + return True diff --git a/apps/permission/signals.py b/apps/permission/signals.py new file mode 100644 index 00000000..a051482e --- /dev/null +++ b/apps/permission/signals.py @@ -0,0 +1,75 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.core.exceptions import PermissionDenied +from logs.middlewares import get_current_authenticated_user + + +EXCLUDED = [ + 'cas_server.proxygrantingticket', + 'cas_server.proxyticket', + 'cas_server.serviceticket', + 'cas_server.user', + 'cas_server.userattributes', + 'contenttypes.contenttype', + 'logs.changelog', + 'migrations.migration', + 'sessions.session', +] + + +def pre_save_object(sender, instance, **kwargs): + """ + Before a model get saved, we check the permissions + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user = get_current_authenticated_user() + if user is None: + # Action performed on shell is always granted + return + + qs = sender.objects.filter(pk=instance.pk).all() + model_name_full = instance._meta.label_lower.split(".") + app_label = model_name_full[0] + model_name = model_name_full[1] + + if qs.exists(): + if user.has_perm(app_label + ".change_" + model_name, instance): + return + + previous = qs.get() + for field in instance._meta.fields: + field_name = field.name + old_value = getattr(previous, field.name) + new_value = getattr(instance, field.name) + if old_value == new_value: + continue + if not user.has_perm(app_label + ".change_" + model_name + "_" + field_name, instance): + raise PermissionDenied + else: + if not user.has_perm(app_label + ".add_" + model_name, instance): + raise PermissionDenied + + +def pre_delete_object(sender, instance, **kwargs): + """ + Before a model get deleted, we check the permissions + """ + # noinspection PyProtectedMember + if instance._meta.label_lower in EXCLUDED: + return + + user = get_current_authenticated_user() + if user is None: + # Action performed on shell is always granted + return + + model_name_full = instance._meta.label_lower.split(".") + app_label = model_name_full[0] + model_name = model_name_full[1] + + if not user.has_perm(app_label + ".delete_" + model_name, instance): + raise PermissionDenied diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 29ff49c5..800c798e 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -139,8 +139,7 @@ REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ - # TODO Maybe replace it with our custom permissions system - 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + 'permission.permissions.StrongDjangoObjectPermissions', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication',