diff --git a/src/redirect/admin.py b/src/redirect/admin.py index bb7d9da..c7e8b5a 100644 --- a/src/redirect/admin.py +++ b/src/redirect/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin +from django.contrib.auth import get_permission_codename from django.contrib.auth.admin import UserAdmin +from django.http import HttpRequest from redirect.models import Redirect @@ -10,5 +12,17 @@ from .models import RedirectUser class RedirectAdmin(admin.ModelAdmin): list_display = ["short_code", "target_url"] + def has_change_permission(self, request: HttpRequest, obj: Redirect = None): + opts = self.opts + codename = get_permission_codename("change", opts) + return request.user.has_perm(f"{opts.app_label}.{codename}", obj) + + def has_view_permission(self, request: HttpRequest, obj: Redirect = None): + opts = self.opts + codename = get_permission_codename("view", opts) + return request.user.has_perm( + f"{opts.app_label}.{codename}", obj + ) or self.has_change_permission(request, obj) + admin.site.register(RedirectUser, UserAdmin) diff --git a/src/redirect/backends.py b/src/redirect/backends.py new file mode 100644 index 0000000..5a51ca2 --- /dev/null +++ b/src/redirect/backends.py @@ -0,0 +1,14 @@ +from django.contrib.auth.backends import BaseBackend, ModelBackend +from django.contrib.auth.models import AbstractUser + +from redirect.models import Redirect + + +class RedirectBackend(ModelBackend): + def has_perm(self, user_obj: AbstractUser, perm, obj=None): + allowed = super().has_perm(user_obj, perm) + if allowed: + return allowed + if obj and isinstance(obj, Redirect): + return obj.owner == user_obj or obj.group_owner in user_obj.groups.all() + return False diff --git a/src/redirect/migrations/0003_auto_20220227_2216.py b/src/redirect/migrations/0003_auto_20220227_2216.py new file mode 100644 index 0000000..e073146 --- /dev/null +++ b/src/redirect/migrations/0003_auto_20220227_2216.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.11 on 2022-02-27 21:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("redirect", "0002_alter_redirect_target_url"), + ] + + operations = [ + migrations.AddField( + model_name="redirect", + name="group_owner", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_redirects", + to="auth.group", + ), + ), + migrations.AddField( + model_name="redirect", + name="owner", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_redirects", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/redirect/migrations/0004_auto_20220227_2241.py b/src/redirect/migrations/0004_auto_20220227_2241.py new file mode 100644 index 0000000..facf053 --- /dev/null +++ b/src/redirect/migrations/0004_auto_20220227_2241.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.11 on 2022-02-27 21:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("redirect", "0003_auto_20220227_2216"), + ] + + operations = [ + migrations.AlterField( + model_name="redirect", + name="group_owner", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_redirects", + to="auth.group", + ), + ), + migrations.AlterField( + model_name="redirect", + name="owner", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_redirects", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/redirect/migrations/0005_auto_20220227_2307.py b/src/redirect/migrations/0005_auto_20220227_2307.py new file mode 100644 index 0000000..6ab651b --- /dev/null +++ b/src/redirect/migrations/0005_auto_20220227_2307.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.11 on 2022-02-27 22:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("redirect", "0004_auto_20220227_2241"), + ] + + operations = [ + migrations.AlterField( + model_name="redirect", + name="group_owner", + field=models.ForeignKey( + blank=True, + help_text="Give any group full permissions over this object.The user will still at least need global view permissions for the admin to work.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_redirects", + to="auth.group", + ), + ), + migrations.AlterField( + model_name="redirect", + name="owner", + field=models.ForeignKey( + blank=True, + help_text="Give any user full permissions over this object. The user will still at least need global view permissions for the admin to work.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_redirects", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/redirect/models.py b/src/redirect/models.py index 65366b0..666529e 100644 --- a/src/redirect/models.py +++ b/src/redirect/models.py @@ -1,10 +1,31 @@ -from django.contrib.auth.models import AbstractUser +from django.conf import settings +from django.contrib.auth.models import AbstractUser, Group from django.db import models class Redirect(models.Model): short_code = models.CharField(max_length=250, blank=False, null=False, unique=True) target_url = models.URLField(max_length=2000) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="owned_redirects", + null=True, + blank=True, + help_text="Give any user full permissions over this object. " + "The user will still at least need global view permissions " + "for the admin to work.", + ) + group_owner = models.ForeignKey( + Group, + on_delete=models.SET_NULL, + related_name="owned_redirects", + null=True, + blank=True, + help_text="Give any group full permissions over this object." + "The user will still at least need global view permissions " + "for the admin to work.", + ) def __str__(self): return f"{self.short_code} => {self.target_url}" diff --git a/src/redirect/tests/test_models.py b/src/redirect/tests/test_models.py index 6e52af9..49b6e71 100644 --- a/src/redirect/tests/test_models.py +++ b/src/redirect/tests/test_models.py @@ -1,7 +1,11 @@ +from django.contrib.auth.models import AbstractUser, Group, Permission +from django.contrib.contenttypes.models import ContentType from django.db import IntegrityError from django.test import TestCase from model_bakery import baker +from redirect.models import Redirect, RedirectUser + class RedirectModelTestCase(TestCase): def test_has_short_code(self): @@ -14,3 +18,62 @@ class RedirectModelTestCase(TestCase): def test_has_target_url(self): baker.make("Redirect", target_url="https://static.augendre.info/potain3") + + def test_can_miss_owner(self): + baker.make("Redirect", owner=None) + + def test_can_have_owner(self): + user = RedirectUser.objects.create_user("user") + baker.make("Redirect", owner=user) + + def test_can_miss_group_owner(self): + baker.make("Redirect", group_owner=None) + + def test_can_have_group_owner(self): + group = Group.objects.create(name="group") + baker.make("Redirect", group_owner=group) + + +class RedirectModelPermissionsTestMixin: + def test_owner_can_change_object(self): + assert self.owner.has_perm("redirect.change_redirect", self.redirect) + + def test_owner_can_delete_object(self): + assert self.owner.has_perm("redirect.delete_redirect", self.redirect) + + def test_owner_can_view_object(self): + assert self.owner.has_perm("redirect.view_redirect", self.redirect) + + def test_non_owner_cant_change_object(self): + assert not self.non_owner.has_perm("redirect.change_redirect", self.redirect) + + def test_non_owner_cant_delete_object(self): + assert not self.non_owner.has_perm("redirect.delete_redirect", self.redirect) + + +class RedirectModelOwnerPermissionsTestCase( + RedirectModelPermissionsTestMixin, TestCase +): + @classmethod + def setUpTestData(cls): + cls.owner: AbstractUser = RedirectUser.objects.create_user("owner") + cls.non_owner: AbstractUser = RedirectUser.objects.create_user("rando") + content_type = ContentType.objects.get_for_model(Redirect) + permission = Permission.objects.get( + codename="view_redirect", + content_type=content_type, + ) + cls.non_owner.user_permissions.add(permission) + cls.redirect = baker.make("Redirect", owner=cls.owner) + + +class RedirectModelGroupOwnerPermissionsTestCase( + RedirectModelPermissionsTestMixin, TestCase +): + @classmethod + def setUpTestData(cls): + cls.owner: AbstractUser = RedirectUser.objects.create_user("owner") + cls.non_owner: AbstractUser = RedirectUser.objects.create_user("rando") + group_owner = Group.objects.create(name="group_owner") + group_owner.user_set.add(cls.owner) + cls.redirect = baker.make("Redirect", group_owner=group_owner) diff --git a/src/shortener/settings.py b/src/shortener/settings.py index 1d50fe4..278f626 100644 --- a/src/shortener/settings.py +++ b/src/shortener/settings.py @@ -86,6 +86,7 @@ TEMPLATES = [ WSGI_APPLICATION = "shortener.wsgi.application" +AUTHENTICATION_BACKENDS = ["redirect.backends.RedirectBackend"] # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases