From 1b06aff1a18e82e4aa33f250496441492d5637f3 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Tue, 4 Apr 2023 14:47:54 +0200 Subject: [PATCH] settings: add django-rule and add contrib app implementing common model and admin implementing object level permissions using django-rule --- jdav_web/contrib/__init__.py | 0 jdav_web/contrib/admin.py | 201 ++++++++++++++++++ jdav_web/contrib/apps.py | 6 + jdav_web/contrib/migrations/__init__.py | 0 jdav_web/contrib/models.py | 10 + jdav_web/contrib/rules.py | 18 ++ jdav_web/contrib/tests.py | 3 + jdav_web/contrib/views.py | 3 + jdav_web/jdav_web/settings/components/base.py | 7 + requirements.txt | 1 + 10 files changed, 249 insertions(+) create mode 100644 jdav_web/contrib/__init__.py create mode 100644 jdav_web/contrib/admin.py create mode 100644 jdav_web/contrib/apps.py create mode 100644 jdav_web/contrib/migrations/__init__.py create mode 100644 jdav_web/contrib/models.py create mode 100644 jdav_web/contrib/rules.py create mode 100644 jdav_web/contrib/tests.py create mode 100644 jdav_web/contrib/views.py diff --git a/jdav_web/contrib/__init__.py b/jdav_web/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py new file mode 100644 index 0000000..d2a4af3 --- /dev/null +++ b/jdav_web/contrib/admin.py @@ -0,0 +1,201 @@ +import copy +from django.contrib.auth import get_permission_codename + +from django.core.exceptions import PermissionDenied +from django.contrib import admin, messages +from django.utils.translation import gettext_lazy as _ +from django.http import HttpResponse, HttpResponseRedirect +from django.urls import path, reverse +from django.db import models +from django.contrib.admin import helpers, widgets +import rules.contrib.admin +from rules.permissions import perm_exists + + +class FieldPermissionsAdminMixin: + field_permissions = {} + + def get_fields(self, request, obj=None): + fields = super(FieldPermissionsAdminMixin, self).get_fields(request, obj) + + def may_field(field): + if field not in self.field_permissions: + return True + return request.user.has_perm(self.field_permissions[field], obj) + + return list(filter(may_field, fields)) + + +class ChangeViewAdminMixin: + def change_view(self, request, object_id, form_url="", extra_context=None): + try: + return super(ChangeViewAdminMixin, self).change_view(request, object_id, + form_url=form_url, + extra_context=extra_context) + except PermissionDenied: + opts = self.opts + obj = self.model.objects.get(pk=object_id) + messages.error(request, + _("You are not allowed to view %(name)s.") % {'name': str(obj)}) + return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (opts.app_label, opts.model_name))) + + +class FilteredQuerysetAdminMixin: + def get_queryset(self, request): + """ + Return a QuerySet of all model instances that can be edited by the + admin site. This is used by changelist_view. + """ + qs = self.model._default_manager.get_queryset() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + queryset = qs + perm = '%s.list_global_%s' % (self.opts.app_label, self.opts.model_name) + if request.user.has_perm(perm): + return queryset + + if not hasattr(request.user, 'member'): + return self.model.objects.none() + + return request.user.member.filter_queryset_by_permissions(queryset, annotate=True, model=self.model) + +#class ObjectPermissionsInlineModelAdminMixin(rules.contrib.admin.ObjectPermissionsInlineModelAdminMixin): + +class CommonAdminMixin(FieldPermissionsAdminMixin, ChangeViewAdminMixin, FilteredQuerysetAdminMixin): + def has_add_permission(self, request, obj=None): + assert obj is None + opts = self.opts + codename = get_permission_codename("add_global", opts) + perm = "%s.%s" % (opts.app_label, codename) + return request.user.has_perm(perm, obj) + + def has_view_permission(self, request, obj=None): + opts = self.opts + if obj is None: + codename = get_permission_codename("view", opts) + else: + codename = get_permission_codename("view_obj", opts) + perm = "%s.%s" % (opts.app_label, codename) + if perm_exists(perm): + return request.user.has_perm(perm, obj) + else: + return self.has_change_permission(request, obj) + + def has_change_permission(self, request, obj=None): + opts = self.opts + if obj is None: + codename = get_permission_codename("view", opts) + else: + codename = get_permission_codename("change_obj", opts) + return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) + + def has_delete_permission(self, request, obj=None): + opts = self.opts + if obj is None: + codename = get_permission_codename("delete_global", opts) + else: + codename = get_permission_codename("delete_obj", opts) + return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """ + COPIED from django to disable related actions + + Hook for specifying the form Field instance for a given database Field + instance. + + If kwargs are given, they're passed to the form Field's constructor. + """ + # If the field specifies choices, we don't need to look for special + # admin widgets - we just need to use a select widget of some kind. + if db_field.choices: + return self.formfield_for_choice_field(db_field, request, **kwargs) + + # ForeignKey or ManyToManyFields + if isinstance(db_field, (models.ForeignKey, models.ManyToManyField)): + # Combine the field kwargs with any options for formfield_overrides. + # Make sure the passed in **kwargs override anything in + # formfield_overrides because **kwargs is more specific, and should + # always win. + if db_field.__class__ in self.formfield_overrides: + kwargs = {**self.formfield_overrides[db_field.__class__], **kwargs} + + # Get the correct formfield. + if isinstance(db_field, models.ForeignKey): + formfield = self.formfield_for_foreignkey(db_field, request, **kwargs) + elif isinstance(db_field, models.ManyToManyField): + formfield = self.formfield_for_manytomany(db_field, request, **kwargs) + + # For non-raw_id fields, wrap the widget with a wrapper that adds + # extra HTML -- the "add other" interface -- to the end of the + # rendered output. formfield can be None if it came from a + # OneToOneField with parent_link=True or a M2M intermediary. + if formfield and db_field.name not in self.raw_id_fields: + formfield.widget = widgets.RelatedFieldWidgetWrapper( + formfield.widget, + db_field.remote_field, + self.admin_site, + ) + + return formfield + + # If we've got overrides for the formfield defined, use 'em. **kwargs + # passed to formfield_for_dbfield override the defaults. + for klass in db_field.__class__.mro(): + if klass in self.formfield_overrides: + kwargs = {**copy.deepcopy(self.formfield_overrides[klass]), **kwargs} + return db_field.formfield(**kwargs) + + # For any other type of field, just call its formfield() method. + return db_field.formfield(**kwargs) + + +class CommonAdminInlineMixin(CommonAdminMixin): + def has_add_permission(self, request, obj): + #assert obj is not None + if obj is None: + return True + if obj.pk is None: + return True + codename = get_permission_codename("add_obj", self.opts) + return request.user.has_perm('%s.%s' % (self.opts.app_label, codename), obj) + + def has_view_permission(self, request, obj=None): # pragma: no cover + if obj is None: + return True + if obj.pk is None: + return True + opts = self.opts + if obj is None: + codename = get_permission_codename("view", opts) + else: + codename = get_permission_codename("view_obj", opts) + perm = "%s.%s" % (opts.app_label, codename) + if perm_exists(perm): + return request.user.has_perm(perm, obj) + else: + return self.has_change_permission(request, obj) + + def has_change_permission(self, request, obj=None): # pragma: no cover + if obj is None: + return True + if obj.pk is None: + return True + opts = self.opts + if opts.auto_created: + for field in opts.fields: + if field.rel and field.rel.to != self.parent_model: + opts = field.rel.to._meta + break + codename = get_permission_codename("change_obj", opts) + return request.user.has_perm("%s.%s" % (opts.app_label, codename), obj) + + def has_delete_permission(self, request, obj=None): # pragma: no cover + if obj is None: + return True + if obj.pk is None: + return True + if self.opts.auto_created: + return self.has_change_permission(request, obj) + return super().has_delete_permission(request, obj) diff --git a/jdav_web/contrib/apps.py b/jdav_web/contrib/apps.py new file mode 100644 index 0000000..5e87acc --- /dev/null +++ b/jdav_web/contrib/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContribConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'contrib' diff --git a/jdav_web/contrib/migrations/__init__.py b/jdav_web/contrib/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jdav_web/contrib/models.py b/jdav_web/contrib/models.py new file mode 100644 index 0000000..58412b0 --- /dev/null +++ b/jdav_web/contrib/models.py @@ -0,0 +1,10 @@ +from django.db import models +from rules.contrib.models import RulesModelBase, RulesModelMixin + +# Create your models here. +class CommonModel(models.Model, RulesModelMixin, metaclass=RulesModelBase): + class Meta: + abstract = True + default_permissions = ( + 'add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view', + ) diff --git a/jdav_web/contrib/rules.py b/jdav_web/contrib/rules.py new file mode 100644 index 0000000..b47c0f9 --- /dev/null +++ b/jdav_web/contrib/rules.py @@ -0,0 +1,18 @@ +from django.contrib.auth import get_permission_codename +import rules.contrib.admin +import rules + +def memberize_user(func): + def inner(user, other): + if not hasattr(user, 'member'): + return False + return func(user.member, other) + return inner + + +def has_global_perm(name): + @rules.predicate + def pred(user, obj): + return user.has_perm(name) + + return pred diff --git a/jdav_web/contrib/tests.py b/jdav_web/contrib/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/jdav_web/contrib/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/jdav_web/contrib/views.py b/jdav_web/contrib/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/jdav_web/contrib/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/jdav_web/jdav_web/settings/components/base.py b/jdav_web/jdav_web/settings/components/base.py index 6088f10..9db1446 100644 --- a/jdav_web/jdav_web/settings/components/base.py +++ b/jdav_web/jdav_web/settings/components/base.py @@ -38,6 +38,7 @@ USE_X_FORWARDED_HOST = True # Application definition INSTALLED_APPS = [ + 'contrib.apps.ContribConfig', 'startpage.apps.StartpageConfig', 'material.apps.MaterialConfig', 'members.apps.MembersConfig', @@ -48,6 +49,7 @@ INSTALLED_APPS = [ 'djcelery_email', 'nested_admin', 'django_celery_beat', + 'rules', 'jet', 'django.contrib.admin', 'django.contrib.auth', @@ -91,6 +93,11 @@ TEMPLATES = [ WSGI_APPLICATION = 'jdav_web.wsgi.application' +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'rules.permissions.ObjectPermissionBackend', +) + # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators diff --git a/requirements.txt b/requirements.txt index 74acb36..4f0958b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,6 +33,7 @@ python-monkey-business==1.0.0 pytz==2021.3 redis==4.1.0 requests==2.27.1 +rules==3.3 six==1.16.0 sqlparse==0.4.2 tzdata==2022.7