From 67d1d9f7b72f11a58e947909ccc198284afe2c43 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 14:26:42 +0200 Subject: [PATCH 01/79] Added permission app --- apps/member/backends.py | 33 +++++++++++ apps/member/models.py | 22 +++++++ apps/permission/__init__.py | 0 apps/permission/admin.py | 3 + apps/permission/apps.py | 5 ++ apps/permission/models.py | 112 ++++++++++++++++++++++++++++++++++++ apps/permission/tests.py | 3 + apps/permission/views.py | 3 + note_kfet/settings.py | 1 + 9 files changed, 182 insertions(+) create mode 100644 apps/member/backends.py create mode 100644 apps/permission/__init__.py create mode 100644 apps/permission/admin.py create mode 100644 apps/permission/apps.py create mode 100644 apps/permission/models.py create mode 100644 apps/permission/tests.py create mode 100644 apps/permission/views.py diff --git a/apps/member/backends.py b/apps/member/backends.py new file mode 100644 index 00000000..0b2edad8 --- /dev/null +++ b/apps/member/backends.py @@ -0,0 +1,33 @@ +from django.contribs.contenttype.models import ContentType +from member.models import Club, Membership, RolePermissions + + +class PermissionBackend(object): + supports_object_permissions = True + supports_anonymous_user = False + supports_inactive_user = False + + def authenticate(self, username, password): + return None + + def permissions(self, user, obj): + for membership in user.memberships.all(): + if not membership.valid() or membership.role is None: + continue + for permission in RolePermissions.objects.get(role=membership.role).permissions.objects.all(): + permission = permission.about(user=user, club=membership.club) + yield permission + + def has_perm(self, user_obj, perm, obj=None): + if obj is None: + return False + perm = perm.split('_') + perm_type = perm[1] + perm_field = perm[2] if len(perm) == 3 else None + return any(permission.applies(obj, perm_type, perm_field) for obj in self.permissions(user_obj, obj)) + + def get_all_permissions(self, user_obj, obj=None): + if obj is None: + return [] + else: + return list(self.permissions(user_obj, obj)) diff --git a/apps/member/models.py b/apps/member/models.py index 70f8ccf7..7eacdc60 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -2,6 +2,8 @@ # Copyright (C) 2018-2019 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import datetime + from django.conf import settings from django.db import models from django.db.models.signals import post_save @@ -9,6 +11,7 @@ from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from django.urls import reverse + class Profile(models.Model): """ An user profile @@ -51,6 +54,7 @@ class Profile(models.Model): def get_absolute_url(self): return reverse('user_detail',args=(self.pk,)) + class Club(models.Model): """ A student club @@ -141,11 +145,29 @@ class Membership(models.Model): verbose_name=_('fee'), ) + def valid(self): + return self.date_start <= datetime.datetime.now() < self.date_end + class Meta: verbose_name = _('membership') verbose_name_plural = _('memberships') +class RolePermissions(models.Model): + """ + Permissions associated with a Role + """ + role = models.ForeignKey( + Role, + on_delete=models.PROTECT, + related_name='+', + verbose_name=_('role'), + ) + permissions = models.ManyToManyField( + 'permission.Permission' + ) + + # @receiver(post_save, sender=settings.AUTH_USER_MODEL) # def save_user_profile(instance, created, **_kwargs): # """ diff --git a/apps/permission/__init__.py b/apps/permission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/permission/admin.py b/apps/permission/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/apps/permission/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/permission/apps.py b/apps/permission/apps.py new file mode 100644 index 00000000..0f46ef08 --- /dev/null +++ b/apps/permission/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PermissionConfig(AppConfig): + name = 'permission' diff --git a/apps/permission/models.py b/apps/permission/models.py new file mode 100644 index 00000000..b7cc8845 --- /dev/null +++ b/apps/permission/models.py @@ -0,0 +1,112 @@ +import json + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + + +class InstancedPermission: + + def __init__(self, model, permission, type, field): + self.model = model + self.permission = permission + self.type = type + self.field = field + + def applies(self, obj, permission_type, field_name=None): + if ContentType.objects.get_for_model(obj) != self.model: + # The permission does not apply to the object + return False + if self.permission is None: + if permission_type == self.type: + if field_name is not None: + return field_name == self.field + else: + return True + else: + return False + elif isinstance(self.permission, dict): + for field in self.permission: + value = getattr(obj, field) + if isinstance(value, models.Model): + value = value.pk + if value != self.permission[field]: + return False + elif isinstance(self.permission, type(obj.pk)): + if obj.pk != self.permission: + return False + if permission_type == self.type: + if field_name: + return field_name == self.field + else: + return True + return False + + def __repr__(self): + if self.field: + return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + else: + return _("Can {type} {model} in {permission}").format(type=self.type, model=self.model, permission=self.permission) + + +class Permission(models.Model): + + PERMISSION_TYPES = [ + ('C', 'add'), + ('R', 'view'), + ('U', 'change'), + ('D', 'delete') + ] + + model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') + + permission = models.TextField() + + type = models.CharField(max_length=15, choices=PERMISSION_TYPES) + + field = models.CharField(max_length=255, blank=True) + + class Meta: + unique_together = ('model', 'permission', 'type', 'field') + + def clean(self): + if self.field and self.type not in {'R', 'U'}: + raise ValidationError(_("Specifying field applies only to view and change permission types.")) + + def save(self): + self.full_clean() + super().save() + + def _about(_self, _permission, **kwargs): + if _permission[0] == 'all': + return None + elif _permission[0] == 'pk': + if _permission[1] in kwargs: + return kwargs[_permission[1]].pk + else: + return None + elif _permission[0] == 'filter': + return {field: _self._about(_permission[1][field], **kwargs) for field in _permission[1]} + else: + return _permission + + def about(self, **kwargs): + permission = json.loads(self.permission) + permission = self._about(permission, **kwargs) + return InstancedPermission(self.model, permission, self.type, self.field) + + def __str__(self): + if self.field: + return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + else: + return _("Can {type} {model} in {permission}").format(type=self.type, model=self.model, permission=self.permission) + + +class UserPermission(models.Model): + + user = models.ForeignKey('auth.User', on_delete=models.CASCADE) + + permission = models.ForeignKey(Permission, on_delete=models.CASCADE) + diff --git a/apps/permission/tests.py b/apps/permission/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/permission/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/permission/views.py b/apps/permission/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/apps/permission/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/note_kfet/settings.py b/note_kfet/settings.py index cfe09f7b..3cd3b717 100644 --- a/note_kfet/settings.py +++ b/note_kfet/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'activity', 'member', 'note', + 'permission' ] MIDDLEWARE = [ From 2a4ab0975353a3c9bb4ba82149c6768cff3c06c1 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:39:37 +0200 Subject: [PATCH 02/79] [permission] Use full names for permission types --- apps/permission/models.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index b7cc8845..73000cbd 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -54,10 +54,10 @@ class InstancedPermission: class Permission(models.Model): PERMISSION_TYPES = [ - ('C', 'add'), - ('R', 'view'), - ('U', 'change'), - ('D', 'delete') + ('add', 'add'), + ('view', 'view'), + ('change', 'change'), + ('delete', 'delete') ] model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') @@ -72,7 +72,7 @@ class Permission(models.Model): unique_together = ('model', 'permission', 'type', 'field') def clean(self): - if self.field and self.type not in {'R', 'U'}: + if self.field and self.type not in {'view', 'change'}: raise ValidationError(_("Specifying field applies only to view and change permission types.")) def save(self): From d826dc9d2076fcc032eafaf9cbe80f38a5c8f2f1 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:40:21 +0200 Subject: [PATCH 03/79] [permission] Permission admin view --- apps/permission/admin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/permission/admin.py b/apps/permission/admin.py index 8c38f3f3..4594468d 100644 --- a/apps/permission/admin.py +++ b/apps/permission/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin -# Register your models here. +from .models import Permission + + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + """ + Admin customisation for Permission + """ + list_display = ('type', 'model', 'field', 'permission') From 1ac63cbed1981005e4bf2b6a0518177b41b81598 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:41:01 +0200 Subject: [PATCH 04/79] [member] Handle unlimited memberships --- apps/member/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/member/models.py b/apps/member/models.py index 2e84dc75..d1c3dea2 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -146,7 +146,10 @@ class Membership(models.Model): ) def valid(self): - return self.date_start <= datetime.datetime.now() < self.date_end + if self.date_end is not None: + return self.date_start <= datetime.datetime.now() < self.date_end + else: + return self.date_start <= datetime.datetime.now() class Meta: verbose_name = _('membership') From 3766a1905df1abff26affb74cae5ecd41e194446 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Wed, 18 Sep 2019 16:44:04 +0200 Subject: [PATCH 05/79] [permission] track migrations directory --- apps/permission/migrations/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/permission/migrations/__init__.py diff --git a/apps/permission/migrations/__init__.py b/apps/permission/migrations/__init__.py new file mode 100644 index 00000000..e69de29b From 94c3a994470efc9f8d58b428a2ccda5caca0b5b9 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sun, 9 Feb 2020 17:35:15 +0100 Subject: [PATCH 06/79] [permission] Rewrite with comments --- apps/permission/models.py | 87 +++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index 73000cbd..b75496ab 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -1,4 +1,6 @@ +import functools import json +import operator from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -9,15 +11,19 @@ from django.utils.translation import gettext_lazy as _ class InstancedPermission: - def __init__(self, model, permission, type, field): + def __init__(self, model, query, type, field): self.model = model - self.permission = permission + self.query = query self.type = type self.field = field def applies(self, obj, permission_type, field_name=None): + """ + Returns True if the permission applies to + the field `field_name` object `obj` + """ if ContentType.objects.get_for_model(obj) != self.model: - # The permission does not apply to the object + # The permission does not apply to the model return False if self.permission is None: if permission_type == self.type: @@ -27,22 +33,10 @@ class InstancedPermission: return True else: return False - elif isinstance(self.permission, dict): - for field in self.permission: - value = getattr(obj, field) - if isinstance(value, models.Model): - value = value.pk - if value != self.permission[field]: - return False - elif isinstance(self.permission, type(obj.pk)): - if obj.pk != self.permission: - return False - if permission_type == self.type: - if field_name: - return field_name == self.field - else: - return True - return False + elif obj in self.model.objects.get(self.query): + return True + else: + return False def __repr__(self): if self.field: @@ -62,11 +56,24 @@ class Permission(models.Model): model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') + # A json encoded Q object with the following grammar + # permission -> [] | {} (the empty permission representing all objects) + # permission -> ['AND', permission, …] + # -> ['OR', permission, …] + # -> ['NOT', permission] + # permission -> {key: value, …} + # key -> string + # value -> int | string | bool | null + # -> [parameter] + # + # Examples: + # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} + # ~Q(is_admin=True) := ['NOT', {'is_admin': ['TYPE', 'bool', 'True']}] permission = models.TextField() - type = models.CharField(max_length=15, choices=PERMISSION_TYPES) + type = models.CharField(max_length=16, choices=PERMISSION_TYPES) - field = models.CharField(max_length=255, blank=True) + field = models.CharField(max_length=256, blank=True) class Meta: unique_together = ('model', 'permission', 'type', 'field') @@ -80,22 +87,38 @@ class Permission(models.Model): super().save() def _about(_self, _permission, **kwargs): - if _permission[0] == 'all': + self = _self + permission = _permission + if len(permission) == 0: + # The permission is either [] or {} and + # applies to all objects of the model + # to represent this we return None return None - elif _permission[0] == 'pk': - if _permission[1] in kwargs: - return kwargs[_permission[1]].pk - else: - return None - elif _permission[0] == 'filter': - return {field: _self._about(_permission[1][field], **kwargs) for field in _permission[1]} + if isinstance(permission, list): + if permission[0] == 'AND': + return functools.reduce(operator.and_, [self._about(permission, **kwargs) for permission in permission[1:]]) + elif permission[0] == 'OR': + return functools.reduce(operator.or_, [self._about(permission, **kwargs) for permission in permission[1:]]) + elif permission[0] == 'NOT': + return ~self._about(permission[1], **kwargs) + elif isinstance(permission, dict): + q_kwargs = {} + for key in permission: + value = permission[key] + if isinstance(value, list): + # It is a parameter we query its primary key + q_kwargs[key] = kwargs[value[0]].pk + else: + q_kwargs[key] = value + return Q(**q_kwargs) else: - return _permission + # TODO: find a better way to crash here + raise Exception("Permission {} is wrong".format(self.permission)) def about(self, **kwargs): permission = json.loads(self.permission) - permission = self._about(permission, **kwargs) - return InstancedPermission(self.model, permission, self.type, self.field) + query = self._about(permission, **kwargs) + return InstancedPermission(self.model, query, self.type, self.field) def __str__(self): if self.field: From 72955ae2d6e58901775a43707716e2141dd23eb7 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sun, 9 Feb 2020 18:14:36 +0100 Subject: [PATCH 07/79] [permission] Renamed Permission.permission and added description field --- apps/permission/models.py | 68 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index b75496ab..5c016806 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -57,26 +57,28 @@ class Permission(models.Model): model = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='+') # A json encoded Q object with the following grammar - # permission -> [] | {} (the empty permission representing all objects) - # permission -> ['AND', permission, …] - # -> ['OR', permission, …] - # -> ['NOT', permission] - # permission -> {key: value, …} - # key -> string - # value -> int | string | bool | null - # -> [parameter] + # query -> [] | {} (the empty query representing all objects) + # query -> ['AND', query, …] + # -> ['OR', query, …] + # -> ['NOT', query] + # query -> {key: value, …} + # key -> string + # value -> int | string | bool | null + # -> [parameter] # # Examples: # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} # ~Q(is_admin=True) := ['NOT', {'is_admin': ['TYPE', 'bool', 'True']}] - permission = models.TextField() + query = models.TextField() - type = models.CharField(max_length=16, choices=PERMISSION_TYPES) + type = models.CharField(max_length=15, choices=PERMISSION_TYPES) - field = models.CharField(max_length=256, blank=True) + field = models.CharField(max_length=255, blank=True) + + description = models.CharField(max_length=255, blank=True) class Meta: - unique_together = ('model', 'permission', 'type', 'field') + unique_together = ('model', 'query', 'type', 'field') def clean(self): if self.field and self.type not in {'view', 'change'}: @@ -86,25 +88,25 @@ class Permission(models.Model): self.full_clean() super().save() - def _about(_self, _permission, **kwargs): + def _about(_self, _query, **kwargs): self = _self - permission = _permission - if len(permission) == 0: - # The permission is either [] or {} and + query = _query + 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 - if isinstance(permission, list): - if permission[0] == 'AND': - return functools.reduce(operator.and_, [self._about(permission, **kwargs) for permission in permission[1:]]) - elif permission[0] == 'OR': - return functools.reduce(operator.or_, [self._about(permission, **kwargs) for permission in permission[1:]]) - elif permission[0] == 'NOT': - return ~self._about(permission[1], **kwargs) - elif isinstance(permission, dict): + if isinstance(query, list): + if query[0] == 'AND': + return functools.reduce(operator.and_, [self._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'OR': + return functools.reduce(operator.or_, [self._about(query, **kwargs) for query in query[1:]]) + elif query[0] == 'NOT': + return ~self._about(query[1], **kwargs) + elif isinstance(query, dict): q_kwargs = {} - for key in permission: - value = permission[key] + 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 @@ -113,18 +115,22 @@ class Permission(models.Model): return Q(**q_kwargs) else: # TODO: find a better way to crash here - raise Exception("Permission {} is wrong".format(self.permission)) + raise Exception("query {} is wrong".format(self.query)) def about(self, **kwargs): - permission = json.loads(self.permission) - query = self._about(permission, **kwargs) + """ + Return an InstancedPermission with the parameters + replaced by their values and the query interpreted + """ + query = json.loads(self.query) + query = self._about(query, **kwargs) return InstancedPermission(self.model, query, self.type, self.field) def __str__(self): if self.field: - return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query) else: - return _("Can {type} {model} in {permission}").format(type=self.type, model=self.model, permission=self.permission) + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) class UserPermission(models.Model): From 2b49effebbf3e09bc0cfb2bd64a7a8f56cebc309 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sun, 9 Feb 2020 18:30:37 +0100 Subject: [PATCH 08/79] [permission] Update admin --- apps/permission/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/permission/admin.py b/apps/permission/admin.py index 4594468d..e93de0c5 100644 --- a/apps/permission/admin.py +++ b/apps/permission/admin.py @@ -8,4 +8,4 @@ class PermissionAdmin(admin.ModelAdmin): """ Admin customisation for Permission """ - list_display = ('type', 'model', 'field', 'permission') + list_display = ('type', 'model', 'field', 'description') From 982a5ae0099eca14d58dcb2cb0f9d50d88c10934 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Thu, 13 Feb 2020 15:59:19 +0100 Subject: [PATCH 09/79] [permission] Add F object support --- apps/permission/models.py | 46 ++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index 5c016806..2ca17e4c 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -5,7 +5,7 @@ import operator from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Q +from django.db.models import F, Q from django.utils.translation import gettext_lazy as _ @@ -58,13 +58,20 @@ class Permission(models.Model): # A json encoded Q object with the following grammar # query -> [] | {} (the empty query representing all objects) - # query -> ['AND', query, …] - # -> ['OR', query, …] - # -> ['NOT', query] - # query -> {key: value, …} - # key -> string - # value -> int | string | bool | null - # -> [parameter] + # query -> ['AND', query, …] AND multiple queries + # | ['OR', query, …] OR multiple queries + # | ['NOT', query] Opposite of query + # query -> {key: value, …} A list of fields and values of a Q object + # key -> string A field name + # value -> int | string | bool | null Literal values + # | [parameter] A parameter + # | {'F': oper} An F object + # oper -> [string] A parameter + # | ['ADD', oper, …] Sum multiple F objects or literal + # | ['SUB', oper, oper] Substract two F objects or literal + # | ['MUL', oper, …] Multiply F objects or literals + # | int | string | bool | null Literal values + # | ['F', string] A field # # Examples: # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} @@ -88,6 +95,26 @@ class Permission(models.Model): self.full_clean() super().save() + @staticmethod + def compute_f(_oper, **kwargs): + oper = _oper + 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, [compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'SUB': + return compute_f(oper[1], **kwargs) - compute_f(oper[2], **kwargs) + elif oper[0] == 'MUL': + return functools.reduce(operator.mul, [compute_f(oper, **kwargs) for oper in oper[1:]]) + elif oper[0] == 'F': + return F(oper[1]) + else: + return oper + # TODO: find a better way to crash here + raise Exception("F is wrong") + def _about(_self, _query, **kwargs): self = _self query = _query @@ -110,6 +137,9 @@ class Permission(models.Model): if isinstance(value, list): # It is a parameter we query its primary key q_kwargs[key] = kwargs[value[0]].pk + elif isinstance(value, dict): + # It is an F object + q_kwargs[key] = compute_f(query['F'], **kwargs) else: q_kwargs[key] = value return Q(**q_kwargs) From 8a9ad0a6e50ef24c3cc2c1519c2be0f0d955e19c Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sat, 7 Mar 2020 09:30:22 +0100 Subject: [PATCH 10/79] [permission] Handle add rights --- apps/permission/models.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/permission/models.py b/apps/permission/models.py index 2ca17e4c..2fcb23cb 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -22,6 +22,9 @@ class InstancedPermission: Returns True if the permission applies to the field `field_name` object `obj` """ + if self.type == 'add': + if permission_type == self.type: + return self.query(obj) if ContentType.objects.get_for_model(obj) != self.model: # The permission does not apply to the model return False @@ -118,6 +121,9 @@ class Permission(models.Model): def _about(_self, _query, **kwargs): self = _self query = _query + if self.type == 'add'): + # Handle add permission differently + return self._about_add(query, **kwargs) if len(query) == 0: # The query is either [] or {} and # applies to all objects of the model @@ -147,6 +153,38 @@ class Permission(models.Model): # TODO: find a better way to crash here raise Exception("query {} is wrong".format(self.query)) + def _about_add(_self, _query, **kwargs): + self = _self + query = _query + if len(query) == 0: + return lambda _: True + if isinstance(query, list): + if query[0] == 'AND': + return lambda obj: functools.reduce(operator.and_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + elif query[0] == 'OR': + return lambda obj: functools.reduce(operator.or_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + elif query[0] == 'NOT': + return lambda obj: not self._about_add(query[1], **kwargs)(obj) + elif isinstance(query, dict): + q_kwargs = {} + 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 + elif isinstance(value, dict): + # It is an F object + q_kwargs[key] = compute_f(query['F'], **kwargs) + else: + q_kwargs[key] = value + def func(obj): + nonlocal q_kwargs + for arg in q_kwargs: + if getattr(obj, arg) != q_kwargs(arg): + return False + return True + return func + def about(self, **kwargs): """ Return an InstancedPermission with the parameters From 5df1f42f435a7ff0c4e895d06c7de6e45a3baeb0 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sat, 7 Mar 2020 10:48:38 +0100 Subject: [PATCH 11/79] [permission] Syntax error --- apps/permission/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/permission/models.py b/apps/permission/models.py index 2fcb23cb..000fe69f 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -121,7 +121,7 @@ class Permission(models.Model): def _about(_self, _query, **kwargs): self = _self query = _query - if self.type == 'add'): + if self.type == 'add': # Handle add permission differently return self._about_add(query, **kwargs) if len(query) == 0: From 9d61e217e9994eb3d60d0c53c65af0f294601f84 Mon Sep 17 00:00:00 2001 From: Benjamin Graillot Date: Sat, 7 Mar 2020 11:21:19 +0100 Subject: [PATCH 12/79] [permission] Only split permission up to 3 --- apps/member/backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/member/backends.py b/apps/member/backends.py index 0b2edad8..9ef9706f 100644 --- a/apps/member/backends.py +++ b/apps/member/backends.py @@ -21,7 +21,7 @@ class PermissionBackend(object): def has_perm(self, user_obj, perm, obj=None): if obj is None: return False - perm = perm.split('_') + perm = perm.split('_', 3) perm_type = perm[1] perm_field = perm[2] if len(perm) == 3 else None return any(permission.applies(obj, perm_type, perm_field) for obj in self.permissions(user_obj, obj)) From 30ce17b644c238183f5e0904c1df621dc209d323 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sat, 7 Mar 2020 13:12:17 +0100 Subject: [PATCH 13/79] Update a lot of things --- apps/logs/signals.py | 4 +++ apps/member/admin.py | 3 +- apps/member/backends.py | 36 +++++++++++--------- apps/member/models.py | 4 +-- apps/permission/admin.py | 3 ++ apps/permission/apps.py | 3 ++ apps/permission/models.py | 70 ++++++++++++++++++-------------------- apps/permission/tests.py | 3 ++ apps/permission/views.py | 3 ++ note_kfet/settings/base.py | 7 ++-- requirements.txt | 1 - 11 files changed, 75 insertions(+), 62 deletions(-) diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 55e0f041..13194e5b 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -78,6 +78,10 @@ def save_object(sender, instance, **kwargs): user, ip = get_user_and_ip(sender) + from django.contrib.auth.models import AnonymousUser + if isinstance(user, AnonymousUser): + user = None + if user is not None and instance._meta.label_lower == "auth.user" and previous: # Don't save last login modifications if instance.last_login != previous.last_login: diff --git a/apps/member/admin.py b/apps/member/admin.py index fb107377..70b00459 100644 --- a/apps/member/admin.py +++ b/apps/member/admin.py @@ -6,7 +6,7 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User from .forms import ProfileForm -from .models import Club, Membership, Profile, Role +from .models import Club, Membership, Profile, Role, RolePermissions class ProfileInline(admin.StackedInline): @@ -40,3 +40,4 @@ admin.site.register(User, CustomUserAdmin) admin.site.register(Club) admin.site.register(Membership) admin.site.register(Role) +admin.site.register(RolePermissions) diff --git a/apps/member/backends.py b/apps/member/backends.py index 9ef9706f..db227cdb 100644 --- a/apps/member/backends.py +++ b/apps/member/backends.py @@ -1,33 +1,37 @@ -from django.contribs.contenttype.models import ContentType +# 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.backends import ModelBackend -class PermissionBackend(object): +class PermissionBackend(ModelBackend): supports_object_permissions = True supports_anonymous_user = False supports_inactive_user = False - def authenticate(self, username, password): - return None - - def permissions(self, user, obj): - for membership in user.memberships.all(): - if not membership.valid() or membership.role is None: + def permissions(self, user): + for membership in Membership.objects.filter(user=user).all(): + if not membership.valid() or membership.roles is None: continue - for permission in RolePermissions.objects.get(role=membership.role).permissions.objects.all(): - permission = permission.about(user=user, club=membership.club) - yield permission + 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) + yield permission 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] perm_field = perm[2] if len(perm) == 3 else None - return any(permission.applies(obj, perm_type, perm_field) for obj in self.permissions(user_obj, obj)) + return any(permission.applies(obj, perm_type, perm_field) for permission in self.permissions(user_obj)) + + def has_module_perms(self, user_obj, app_label): + return False def get_all_permissions(self, user_obj, obj=None): - if obj is None: - return [] - else: - return list(self.permissions(user_obj, obj)) + return list(self.permissions(user_obj)) diff --git a/apps/member/models.py b/apps/member/models.py index c90ab15c..1ca82af0 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -154,9 +154,9 @@ class Membership(models.Model): def valid(self): if self.date_end is not None: - return self.date_start <= datetime.datetime.now() < self.date_end + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal() else: - return self.date_start <= datetime.datetime.now() + return self.date_start.toordinal() <= datetime.datetime.now().toordinal() class Meta: verbose_name = _('membership') diff --git a/apps/permission/admin.py b/apps/permission/admin.py index e93de0c5..f7a9b4b5 100644 --- a/apps/permission/admin.py +++ b/apps/permission/admin.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-lateré + from django.contrib import admin from .models import Permission diff --git a/apps/permission/apps.py b/apps/permission/apps.py index 0f46ef08..c9c912a5 100644 --- a/apps/permission/apps.py +++ b/apps/permission/apps.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from django.apps import AppConfig diff --git a/apps/permission/models.py b/apps/permission/models.py index 000fe69f..9584f59f 100644 --- a/apps/permission/models.py +++ b/apps/permission/models.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + import functools import json import operator @@ -24,28 +27,25 @@ class InstancedPermission: """ if self.type == 'add': if permission_type == self.type: - return self.query(obj) + return obj in self.model.modelclass().objects.get(self.query) if ContentType.objects.get_for_model(obj) != self.model: # The permission does not apply to the model return False - if self.permission is None: - if permission_type == self.type: - if field_name is not None: - return field_name == self.field - else: - return True - else: + if permission_type == self.type: + if field_name and field_name != self.field: return False - elif obj in self.model.objects.get(self.query): - return True + return obj in self.model.model_class().objects.filter(self.query).all() else: return False def __repr__(self): if self.field: - return _("Can {type} {model}.{field} in {permission}").format(type=self.type, model=self.model, field=self.field, permission=self.permission) + return _("Can {type} {model}.{field} in {query}").format(type=self.type, model=self.model, field=self.field, query=self.query) else: - return _("Can {type} {model} in {permission}").format(type=self.type, model=self.model, permission=self.permission) + return _("Can {type} {model} in {query}").format(type=self.type, model=self.model, query=self.query) + + def __str__(self): + return self.__repr__() class Permission(models.Model): @@ -61,24 +61,24 @@ class Permission(models.Model): # A json encoded Q object with the following grammar # query -> [] | {} (the empty query representing all objects) - # query -> ['AND', query, …] AND multiple queries - # | ['OR', query, …] OR multiple queries - # | ['NOT', query] Opposite of query + # query -> ["AND", query, …] AND multiple queries + # | ["OR", query, …] OR multiple queries + # | ["NOT", query] Opposite of query # query -> {key: value, …} A list of fields and values of a Q object # key -> string A field name # value -> int | string | bool | null Literal values # | [parameter] A parameter - # | {'F': oper} An F object + # | {"F": oper} An F object # oper -> [string] A parameter - # | ['ADD', oper, …] Sum multiple F objects or literal - # | ['SUB', oper, oper] Substract two F objects or literal - # | ['MUL', oper, …] Multiply F objects or literals + # | ["ADD", oper, …] Sum multiple F objects or literal + # | ["SUB", oper, oper] Substract two F objects or literal + # | ["MUL", oper, …] Multiply F objects or literals # | int | string | bool | null Literal values - # | ['F', string] A field + # | ["F", string] A field # # Examples: - # Q(is_admin=True) := {'is_admin': ['TYPE', 'bool', 'True']} - # ~Q(is_admin=True) := ['NOT', {'is_admin': ['TYPE', 'bool', 'True']}] + # Q(is_superuser=True) := {"is_superuser": true} + # ~Q(is_superuser=True) := ["NOT", {"is_superuser": true}] query = models.TextField() type = models.CharField(max_length=15, choices=PERMISSION_TYPES) @@ -94,23 +94,22 @@ class Permission(models.Model): if self.field and self.type not in {'view', 'change'}: raise ValidationError(_("Specifying field applies only to view and change permission types.")) - def save(self): + def save(self, **kwargs): self.full_clean() super().save() @staticmethod - def compute_f(_oper, **kwargs): - oper = _oper + 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, [compute_f(oper, **kwargs) for oper in oper[1:]]) + return functools.reduce(operator.add, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) elif oper[0] == 'SUB': - return compute_f(oper[1], **kwargs) - compute_f(oper[2], **kwargs) + return Permission.compute_f(oper[1], **kwargs) - Permission.compute_f(oper[2], **kwargs) elif oper[0] == 'MUL': - return functools.reduce(operator.mul, [compute_f(oper, **kwargs) for oper in oper[1:]]) + return functools.reduce(operator.mul, [Permission.compute_f(oper, **kwargs) for oper in oper[1:]]) elif oper[0] == 'F': return F(oper[1]) else: @@ -118,9 +117,7 @@ class Permission(models.Model): # TODO: find a better way to crash here raise Exception("F is wrong") - def _about(_self, _query, **kwargs): - self = _self - query = _query + def _about(self, query, **kwargs): if self.type == 'add': # Handle add permission differently return self._about_add(query, **kwargs) @@ -145,7 +142,7 @@ class Permission(models.Model): q_kwargs[key] = kwargs[value[0]].pk elif isinstance(value, dict): # It is an F object - q_kwargs[key] = compute_f(query['F'], **kwargs) + q_kwargs[key] = Permission.compute_f(query['F'], **kwargs) else: q_kwargs[key] = value return Q(**q_kwargs) @@ -153,16 +150,15 @@ class Permission(models.Model): # TODO: find a better way to crash here raise Exception("query {} is wrong".format(self.query)) - def _about_add(_self, _query, **kwargs): - self = _self + def _about_add(self, _query, **kwargs): query = _query if len(query) == 0: return lambda _: True if isinstance(query, list): if query[0] == 'AND': - return lambda obj: functools.reduce(operator.and_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + return lambda obj: functools.reduce(operator.and_, [self._about_add(q, **kwargs)(obj) for q in query[1:]]) elif query[0] == 'OR': - return lambda obj: functools.reduce(operator.or_, [self._about_add(query, **kwargs)(obj) for query in query[1:]]) + return lambda obj: functools.reduce(operator.or_, [self._about_add(q, **kwargs)(obj) for q in query[1:]]) elif query[0] == 'NOT': return lambda obj: not self._about_add(query[1], **kwargs)(obj) elif isinstance(query, dict): @@ -174,7 +170,7 @@ class Permission(models.Model): q_kwargs[key] = kwargs[value[0]].pk elif isinstance(value, dict): # It is an F object - q_kwargs[key] = compute_f(query['F'], **kwargs) + q_kwargs[key] = Permission.compute_f(query['F'], **kwargs) else: q_kwargs[key] = value def func(obj): diff --git a/apps/permission/tests.py b/apps/permission/tests.py index 7ce503c2..b5d5752e 100644 --- a/apps/permission/tests.py +++ b/apps/permission/tests.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from django.test import TestCase # Create your tests here. diff --git a/apps/permission/views.py b/apps/permission/views.py index 91ea44a2..8d81fd33 100644 --- a/apps/permission/views.py +++ b/apps/permission/views.py @@ -1,3 +1,6 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + from django.shortcuts import render # Create your views here. diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 63b7ff24..20937fac 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -37,7 +37,6 @@ INSTALLED_APPS = [ # External apps 'polymorphic', - 'guardian', 'reversion', 'crispy_forms', 'django_tables2', @@ -134,8 +133,8 @@ PASSWORD_HASHERS = [ # Django Guardian object permissions AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', # this is default - 'guardian.backends.ObjectPermissionBackend', + #'django.contrib.auth.backends.ModelBackend', # this is default + 'member.backends.PermissionBackend', 'cas.backends.CASBackend', ) @@ -153,8 +152,6 @@ REST_FRAMEWORK = { ANONYMOUS_USER_NAME = None # Disable guardian anonymous user -GUARDIAN_GET_CONTENT_TYPE = 'polymorphic.contrib.guardian.get_polymorphic_base_content_type' - # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/requirements.txt b/requirements.txt index 244690bc..9a5eaa22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ django-cas-server==1.1.0 django-crispy-forms==1.7.2 django-extensions==2.1.9 django-filter==2.2.0 -django-guardian==2.1.0 django-polymorphic==2.0.3 djangorestframework==3.9.0 django-rest-polymorphic==0.1.8 From 3ad6d9e87031383906db64b9f7418018b9863035 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 20:36:38 +0100 Subject: [PATCH 14/79] Documentation logs middleware --- apps/logs/middlewares.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/logs/middlewares.py b/apps/logs/middlewares.py index 77f749b9..e4d76f07 100644 --- a/apps/logs/middlewares.py +++ b/apps/logs/middlewares.py @@ -14,19 +14,31 @@ _thread_locals = local() def _set_current_user_and_ip(user=None, ip=None): + """ + Store current user and IP address in the local thread. + """ setattr(_thread_locals, USER_ATTR_NAME, user) setattr(_thread_locals, IP_ATTR_NAME, ip) def get_current_user(): + """ + :return: The user that performed a request (may be anonymous) + """ return getattr(_thread_locals, USER_ATTR_NAME, None) def get_current_ip(): + """ + :return: The IP address of the user that has performed a request + """ return getattr(_thread_locals, IP_ATTR_NAME, None) def get_current_authenticated_user(): + """ + :return: The user that performed a request (must be authenticated, return None if anonymous) + """ current_user = get_current_user() if isinstance(current_user, AnonymousUser): return None @@ -35,21 +47,31 @@ def get_current_authenticated_user(): class LogsMiddleware(object): """ - This middleware get the current user with his or her IP address on each request. + This middleware gets the current user with his or her IP address on each request. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): + """ + This function is called on each request. + :param request: The HTTP Request + :return: The HTTP Response + """ user = request.user + # Get request IP from the headers + # The `REMOTE_ADDR` field may not contain the true IP, if there is a proxy if 'HTTP_X_FORWARDED_FOR' in request.META: ip = request.META.get('HTTP_X_FORWARDED_FOR') else: ip = request.META.get('REMOTE_ADDR') + # The user and the IP address are stored in the current thread _set_current_user_and_ip(user, ip) + # The request is then analysed, and the response is generated response = self.get_response(request) + # We flush the connected user and the IP address for the next requests _set_current_user_and_ip(None, None) return response From 7f432f5bc5c12d77b366db9e199795125f75f94c Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Wed, 11 Mar 2020 20:59:15 +0100 Subject: [PATCH 15/79] Fix categories in front (fields weren't well named) --- templates/note/conso_form.html | 14 +++++++------- templates/note/transactiontemplate_list.html | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/templates/note/conso_form.html b/templates/note/conso_form.html index 2c2066e8..8945a919 100644 --- a/templates/note/conso_form.html +++ b/templates/note/conso_form.html @@ -58,16 +58,16 @@ {# Regroup buttons under categories #} - {% regroup transaction_templates by template_type as template_types %} + {% regroup transaction_templates by category as categories %}
{# Tabs for button categories #}