Add instance-level ownership

This commit is contained in:
Gabriel Augendre 2022-02-27 23:07:57 +01:00
parent 20d67f6f9f
commit 7e6bcf3e40
8 changed files with 228 additions and 1 deletions

View file

@ -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)

14
src/redirect/backends.py Normal file
View file

@ -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

View file

@ -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,
),
),
]

View file

@ -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,
),
),
]

View file

@ -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,
),
),
]

View file

@ -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}"

View file

@ -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)

View file

@ -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