Repository: django-beam/django-beam

Issue:
permissions
idea: add them to viewinfo and use that as source

Discussion:

Patch:
diff --git a/src/beam/components.py b/src/beam/components.py
index 9293317..572deac 100644
--- a/src/beam/components.py
+++ b/src/beam/components.py
@@ -21,10 +21,19 @@ class BaseComponent:
 
         self.name = name
         self.verbose_name = verbose_name or self.name
-        self.permission = permission  # FIXME string / list / callable
         self.url_name = url_name
         self.url_kwargs = url_kwargs
 
+        if isinstance(permission, str):
+            context = {"component": self}
+            if getattr(self, "model", None) is not None:
+                opts = self.model._meta
+                context["model_name"] = opts.model_name
+                context["app_label"] = opts.app_label
+            self.permission = permission.format(**context)
+        else:
+            self.permission = permission
+
     @classmethod
     def get_arguments(cls) -> Set[str]:
         """
@@ -39,6 +48,16 @@ class BaseComponent:
     def get_context(self):
         return {name: getattr(self, name) for name in self.get_arguments()}
 
+    def has_perm(self, user, obj=None):
+        if self.permission is None:
+            return True
+        if not user:
+            return False
+        if callable(self.permission):
+            return self.permission(user, obj=obj)
+        # the ModelBackend returns False as soon as we supply an obj so we can't pass that here
+        return user.has_perm(self.permission)
+
     def reverse(self, obj=None, extra_kwargs=None):
         if not obj:
             return reverse(self.url_name)
@@ -64,9 +83,10 @@ class Component(BaseComponent):
         model=None,
         inline_classes=None,
         link_layout=None,
+        name=None,
+        url_name=None,
         **kwargs
     ):
-        super().__init__(**kwargs)
         self.url = url
 
         if not view_class:
@@ -82,7 +102,7 @@ class Component(BaseComponent):
 
         if model is None and queryset is None:
             raise ValueError(
-                "Component {} needs at least one of model, queryset".format(self.name)
+                "Component {} needs at least one of model, queryset".format(name)
             )
         elif model is not None and queryset is None:
             queryset = model._default_manager
@@ -92,14 +112,15 @@ class Component(BaseComponent):
         self.model = model
         self.queryset = queryset.all()  # ensure we don't keep stale copies of querysets
 
-        if not self.url_name:
-            self.url_name = "{}_{}_{}".format(
-                self.model._meta.app_label, self.model._meta.model_name, self.name
+        if not url_name:
+            url_name = "{}_{}_{}".format(
+                self.model._meta.app_label, self.model._meta.model_name, name
             )
 
         self.link_layout = (
-            link_layout if link_layout is not None else ["!{}".format(self.name), "..."]
+            link_layout if link_layout is not None else ["!{}".format(name), "..."]
         )
+        super().__init__(name=name, url_name=url_name, **kwargs)
 
 
 class FormComponent(Component):
diff --git a/src/beam/contrib/autocomplete_light/__init__.py b/src/beam/contrib/autocomplete_light/__init__.py
index 6eeaae8..c08b516 100644
--- a/src/beam/contrib/autocomplete_light/__init__.py
+++ b/src/beam/contrib/autocomplete_light/__init__.py
@@ -5,11 +5,11 @@ from django.db.models import Q
 from django.utils.translation import ugettext_lazy as _
 
 from beam.components import Component
-from beam.views import ViewSetContextMixin
+from beam.views import ComponentMixin
 from beam.viewsets import BaseViewSet
 
 
-class BaseAutocomplete(ViewSetContextMixin, autocomplete.Select2QuerySetView):
+class BaseAutocomplete(ComponentMixin, autocomplete.Select2QuerySetView):
     @property
     def lookup_type(self):
         return self.component.autocomplete_lookup_type
@@ -87,6 +87,7 @@ class AutocompleteMixin(BaseViewSet):
     autocomplete_lookup_type = "istartswith"
     autocomplete_search_fields = None
     autocomplete_result_label = None
+    autocomplete_permission = "{app_label}.view_{model_name}"
 
     def get_component_classes(self):
         return [
diff --git a/src/beam/contrib/reversion/views.py b/src/beam/contrib/reversion/views.py
index 538ead9..dfc4a1f 100644
--- a/src/beam/contrib/reversion/views.py
+++ b/src/beam/contrib/reversion/views.py
@@ -2,13 +2,15 @@ from django.contrib import messages
 from django.core.exceptions import ImproperlyConfigured
 from django.db import connection, transaction
 from django.http import HttpResponseRedirect
+from django.views import View
+from django.shortcuts import get_object_or_404
 from django.utils.encoding import force_text
 from django.utils.translation import ugettext as _
 from django.views import generic
 from reversion import set_comment, RevertError
 from reversion.models import Version
 
-from beam.views import ViewSetContextMixin, DetailView
+from beam.views import ComponentMixin, DetailView
 
 
 class _RollBackRevisionView(Exception):
@@ -17,12 +19,42 @@ class _RollBackRevisionView(Exception):
         self.response = response
 
 
+class VersionRestoreView(ComponentMixin, View):
+    def post(self, request, *args, **kwargs):
+        self.version = Version.objects.get_for_object_reference(
+            self.model, kwargs["pk"]
+        ).get(pk=kwargs["version_id"])
+
+        # Check that database transactions are supported.
+        if not connection.features.uses_savepoints:
+            raise ImproperlyConfigured(
+                "Cannot use with a database that does not support savepoints."
+            )
+
+        # Run the view.
+        try:
+            with transaction.atomic(using=self.version.db):
+                # Revert the revision.
+                with self.viewset.create_revision(request):
+                    set_comment(_("revert to version {}".format(self.version.pk)))
+                    self.version.revision.revert(delete=True)
+                    messages.success(request, "Reverted")
+                    return HttpResponseRedirect(
+                        self.viewset.links["detail"].reverse(self.version.object)
+                    )
+        except RevertError as ex:
+            messages.error(request, force_text(ex))
+            return HttpResponseRedirect(
+                self.viewset.links["detail"].reverse(self.version.object)
+            )
+
+
 class VersionDetailView(DetailView):
     def get_context_data(self, **kwargs):
         kwargs["version"] = self.version
         return super().get_context_data(**kwargs)
 
-    def dispatch(self, request, *args, **kwargs):
+    def get(self, request, *args, **kwargs):
         self.version = Version.objects.get_for_object_reference(
             self.model, kwargs["pk"]
         ).get(pk=kwargs["version_id"])
@@ -30,29 +62,19 @@ class VersionDetailView(DetailView):
         # Check that database transactions are supported.
         if not connection.features.uses_savepoints:
             raise ImproperlyConfigured(
-                "Cannot use VersionAdmin with a database that does not support savepoints."
+                "Cannot use with a database that does not support savepoints."
             )
 
         # Run the view.
         try:
             with transaction.atomic(using=self.version.db):
-                # Revert the revision.
-                if request.method == "POST":
-                    with self.viewset.create_revision(request):
-                        set_comment(_("revert to version {}".format(self.version.pk)))
-                        self.version.revision.revert(delete=True)
-                        messages.success(request, "Reverted")
-                        return HttpResponseRedirect(
-                            self.viewset.links["detail"].reverse(self.version.object)
-                        )
-                else:
-                    self.version.revision.revert(delete=True)
-                    response = super().dispatch(request, *args, **kwargs)
-                    if hasattr(response, "render"):
-                        response.render()
-                    raise _RollBackRevisionView(
-                        response
-                    )  # Raise exception to undo the transaction and revision.
+                self.version.revision.revert(delete=True)
+                response = super().get(request, *args, **kwargs)
+                if hasattr(response, "render"):
+                    response.render()
+                raise _RollBackRevisionView(
+                    response
+                )  # Raise exception to undo the transaction and revision.
         except RevertError as ex:
             messages.error(request, force_text(ex))
             return HttpResponseRedirect(
@@ -65,7 +87,7 @@ class VersionDetailView(DetailView):
         return ["beam_reversion/version_detail.html"]
 
 
-class VersionListView(ViewSetContextMixin, generic.DetailView):
+class VersionListView(ComponentMixin, generic.DetailView):
     def get_template_names(self):
         return ["beam_reversion/version_list.html"]
 
diff --git a/src/beam/contrib/reversion/viewsets.py b/src/beam/contrib/reversion/viewsets.py
index afbb9f4..d3b7ad1 100644
--- a/src/beam/contrib/reversion/viewsets.py
+++ b/src/beam/contrib/reversion/viewsets.py
@@ -1,5 +1,5 @@
 from contextlib import contextmanager
-from typing import Iterable
+from typing import Iterable, List
 
 from django.utils.translation import ugettext_lazy as _
 from reversion import (
@@ -14,7 +14,20 @@ from reversion import (
 from beam import RelatedInline
 from beam import ViewSet
 from beam.viewsets import BaseViewSet, Component
-from .views import VersionDetailView, VersionListView
+from .views import VersionRestoreView, VersionDetailView, VersionListView
+
+
+class VersionRestoreMixin(BaseViewSet):
+    version_restore_view_class = VersionRestoreView
+    version_restore_url = "<str:pk>/versions/<str:version_id>/restore/"
+    version_restore_url_kwargs = ["pk"]
+    version_restore_verbose_name = None
+    version_restore_url_name = None
+    version_restore_link_layout: List[str] = []
+    version_restore_permission = "{app_label}.change_{model_name}"
+
+    def get_component_classes(self):
+        return super().get_component_classes() + [("version_restore", Component)]
 
 
 class VersionDetailMixin(BaseViewSet):
@@ -24,6 +37,7 @@ class VersionDetailMixin(BaseViewSet):
     version_detail_verbose_name = _("show version")
     version_detail_url_name = None
     version_detail_link_layout = ["version_list"]
+    version_detail_permission = "{app_label}.view_{model_name}"
 
     def get_component_classes(self):
         return super().get_component_classes() + [("version_detail", Component)]
@@ -36,12 +50,13 @@ class VersionListMixin(BaseViewSet):
     version_list_verbose_name = _("history")
     version_list_url_name = None
     version_list_link_layout = ["detail"]
+    version_list_permission = "{app_label}.view_{model_name}"
 
     def get_component_classes(self):
         return super().get_component_classes() + [("version_list", Component)]
 
 
-class VersionViewSetMixin(VersionDetailMixin, VersionListMixin):
+class VersionViewSetMixin(VersionDetailMixin, VersionRestoreMixin, VersionListMixin):
     versioned_component_names = ["create", "update"]
 
     def __init__(self) -> None:
diff --git a/src/beam/templatetags/beam_tags.py b/src/beam/templatetags/beam_tags.py
index 72f64d3..847f105 100644
--- a/src/beam/templatetags/beam_tags.py
+++ b/src/beam/templatetags/beam_tags.py
@@ -226,6 +226,7 @@ PRESERVED_GET_PARAMS = [
 @register.simple_tag(takes_context=True)
 def get_visible_links(
     context: RequestContext,
+    user,
     links: Dict[str, BaseComponent],
     link_layout: List[str],
     obj: Model = None,
@@ -242,6 +243,8 @@ def get_visible_links(
 
     visible_links = []
     for link in layout_links(links, link_layout):
+        if not link.has_perm(user, obj=obj):
+            continue
         try:
             url = link.reverse(obj, extra_kwargs=extra_kwargs)
         except NoReverseMatch:
diff --git a/src/beam/themes/bootstrap4/templates/beam/partials/links.html b/src/beam/themes/bootstrap4/templates/beam/partials/links.html
index 4fa2b17..a6c0a9e 100644
--- a/src/beam/themes/bootstrap4/templates/beam/partials/links.html
+++ b/src/beam/themes/bootstrap4/templates/beam/partials/links.html
@@ -1,7 +1,7 @@
 {% load beam_tags %}
 {% load i18n %}
 
-{% get_visible_links links link_layout object as link_urls %}
+{% get_visible_links request.user links link_layout object as link_urls %}
 
 {% if link_urls %}
     <div class="btn-group" role="group" aria-label="{% trans "Actions" %}">
diff --git a/src/beam/themes/bootstrap4/templates/beam/partials/list_links.html b/src/beam/themes/bootstrap4/templates/beam/partials/list_links.html
index e864c73..53454c7 100644
--- a/src/beam/themes/bootstrap4/templates/beam/partials/list_links.html
+++ b/src/beam/themes/bootstrap4/templates/beam/partials/list_links.html
@@ -1,7 +1,7 @@
 {% load beam_tags %}
 {% load i18n %}
 
-{% get_visible_links links link_layout object as link_urls %}
+{% get_visible_links request.user links link_layout object as link_urls %}
 
 {% if link_urls %}
     <div class="btn-group" role="group" aria-label="{% trans "Actions" %}">
diff --git a/src/beam/views.py b/src/beam/views.py
index eb24d3e..eaa6678 100644
--- a/src/beam/views.py
+++ b/src/beam/views.py
@@ -2,7 +2,7 @@ from typing import List, Type, Tuple, Optional
 
 from django.apps import apps
 from django.contrib.admin.utils import NestedObjects
-from django.core.exceptions import FieldDoesNotExist
+from django.core.exceptions import FieldDoesNotExist, PermissionDenied
 from django.db import router
 from django.forms import all_valid
 from django.http import HttpResponse, HttpResponseForbidden
@@ -16,7 +16,7 @@ from beam.registry import register, default_registry
 from .inlines import RelatedInline
 
 
-class ViewSetContextMixin(ContextMixin):
+class ComponentMixin(ContextMixin):
     component = None
     viewset = None
 
@@ -50,6 +50,22 @@ class ViewSetContextMixin(ContextMixin):
     def get_inline_classes(self):
         return self.component.inline_classes
 
+    def has_perm(self):
+        try:
+            obj = self.get_object()
+        except AttributeError:
+            obj = None
+        return self.component.has_perm(self.request.user, obj)
+
+    def handle_no_permission(self):
+        raise PermissionDenied("You shall not pass")
+
+    def dispatch(self, request, *args, **kwargs):
+        if not self.has_perm():
+            self.handle_no_permission()
+
+        return super().dispatch(request, *args, **kwargs)
+
 
 class InlinesMixin(ContextMixin):
     inline_classes: List[Type[RelatedInline]] = []
@@ -129,7 +145,7 @@ class UpdateWithInlinesMixin(InlinesMixin):
         )
 
 
-class CreateView(ViewSetContextMixin, CreateWithInlinesMixin, generic.CreateView):
+class CreateView(ComponentMixin, CreateWithInlinesMixin, generic.CreateView):
     def get_template_names(self):
         return super().get_template_names() + ["beam/create.html"]
 
@@ -158,7 +174,7 @@ class CreateView(ViewSetContextMixin, CreateWithInlinesMixin, generic.CreateView
         )
 
 
-class UpdateView(ViewSetContextMixin, UpdateWithInlinesMixin, generic.UpdateView):
+class UpdateView(ComponentMixin, UpdateWithInlinesMixin, generic.UpdateView):
     def get_template_names(self):
         return super().get_template_names() + ["beam/update.html"]
 
@@ -166,7 +182,7 @@ class UpdateView(ViewSetContextMixin, UpdateWithInlinesMixin, generic.UpdateView
         return self.viewset.links["detail"].reverse(obj=self.object)
 
 
-class SortableListMixin(ViewSetContextMixin):
+class SortableListMixin(ComponentMixin):
     sort_param = "o"
     sort_separator = ","
 
@@ -253,7 +269,7 @@ class SortableListMixin(ViewSetContextMixin):
 
 
 class ListView(
-    SearchableListMixin, SortableListMixin, ViewSetContextMixin, generic.ListView
+    SearchableListMixin, SortableListMixin, ComponentMixin, generic.ListView
 ):
     @property
     def search_fields(self):
@@ -277,12 +293,12 @@ class ListView(
         return context
 
 
-class DetailView(ViewSetContextMixin, InlinesMixin, generic.DetailView):
+class DetailView(ComponentMixin, InlinesMixin, generic.DetailView):
     def get_template_names(self):
         return super().get_template_names() + ["beam/detail.html"]
 
 
-class DeleteView(ViewSetContextMixin, InlinesMixin, generic.DeleteView):
+class DeleteView(ComponentMixin, InlinesMixin, generic.DeleteView):
     def get_template_names(self):
         return super().get_template_names() + ["beam/delete.html"]
 
diff --git a/src/beam/viewsets.py b/src/beam/viewsets.py
index 1c44856..c2c73f2 100644
--- a/src/beam/viewsets.py
+++ b/src/beam/viewsets.py
@@ -132,6 +132,9 @@ class ListMixin(BaseViewSet):
     list_queryset: QuerySet
     list_inline_classes: List[RelatedInline]
     list_form_class: ModelForm
+    list_permission = "{app_label}.view_{model_name}"
+
+    # add / change / delete / view
 
 
 class CreateMixin(BaseViewSet):
@@ -149,6 +152,7 @@ class CreateMixin(BaseViewSet):
     create_inline_classes: List[RelatedInline]
     create_form_class: ModelForm
     create_link_layout = ["!create", "!update", "..."]
+    create_permission = "{app_label}.add_{model_name}"
 
 
 class UpdateMixin(BaseViewSet):
@@ -166,6 +170,7 @@ class UpdateMixin(BaseViewSet):
     update_inline_classes: List[RelatedInline]
     update_form_class: ModelForm
     update_link_layout = ["!create", "!update", "list", "...", "detail"]
+    update_permission = "{app_label}.change_{model_name}"
 
 
 class DetailMixin(BaseViewSet):
@@ -182,6 +187,7 @@ class DetailMixin(BaseViewSet):
     detail_queryset: QuerySet
     detail_inline_classes: List[RelatedInline]
     detail_link_layout = ["!detail", "...", "update"]
+    detail_permission = "{app_label}.view_{model_name}"
 
 
 class DeleteMixin(BaseViewSet):
@@ -198,6 +204,7 @@ class DeleteMixin(BaseViewSet):
     delete_queryset: QuerySet
     delete_inline_classes: List[RelatedInline]
     delete_link_layout = ["!delete", "..."]
+    delete_permission = "{app_label}.delete_{model_name}"
 
 
 class ViewSet(


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c258816..a2f99d8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,12 +15,17 @@ def pytest_configure(config):
             "crispy_forms",
             "django.contrib.contenttypes",
             "django.contrib.admin",
+            "django.contrib.sessions",
             "django.contrib.auth",
             "reversion",
             "beam.contrib.reversion",
         ],
         SECRET_KEY="secret_key_for_testing",
         ROOT_URLCONF="testapp.urls",
+        MIDDLEWARE=[
+            "django.contrib.sessions.middleware.SessionMiddleware",
+            "django.contrib.auth.middleware.AuthenticationMiddleware",
+        ],
         TEMPLATES=[
             {
                 "BACKEND": "django.template.backends.django.DjangoTemplates",
diff --git a/tests/test_contrib_reversion.py b/tests/test_contrib_reversion.py
index 342a4d5..99d108e 100644
--- a/tests/test_contrib_reversion.py
+++ b/tests/test_contrib_reversion.py
@@ -2,14 +2,17 @@ from django.contrib.auth import get_user_model
 from django.contrib.messages.apps import MessagesConfig
 from django.contrib.messages.middleware import MessageMiddleware
 from django.contrib.sessions.middleware import SessionMiddleware
+from django.core.exceptions import PermissionDenied
 from django.test import RequestFactory
 from reversion.models import Version
 from pytest import mark
 from reversion import is_registered, set_comment
 
+import pytest
 from beam import RelatedInline
 from beam.contrib.reversion.viewsets import VersionViewSet
 from beam.registry import RegistryType
+from test_views import user_with_perms
 from testapp.models import Dragonfly, Petaluridae, Sighting
 
 
@@ -48,8 +51,8 @@ def test_unrelated_models_are_not_registered():
 
 
 @mark.django_db
-def test_using_create_view_creates_a_revision():
-    user = get_user_model().objects.create()
+def test_using_create_view_creates_a_revision(django_user_model):
+    user = user_with_perms(django_user_model, ["testapp.add_dragonfly"])
     create_view = VersionedDragonflyViewSet()._get_view(
         VersionedDragonflyViewSet().components["create"]
     )
@@ -87,10 +90,10 @@ def test_using_create_view_creates_a_revision():
 
 
 @mark.django_db
-def test_using_update_view_creates_a_revision():
+def test_using_update_view_creates_a_revision(django_user_model):
+    user = user_with_perms(django_user_model, ["testapp.change_dragonfly"])
     alpha = Dragonfly.objects.create(name="alpha", age=47)
 
-    user = get_user_model().objects.create()
     update_view = VersionedDragonflyViewSet()._get_view(
         VersionedDragonflyViewSet().components["update"]
     )
@@ -122,12 +125,11 @@ def test_using_update_view_creates_a_revision():
 
 
 @mark.django_db
-def test_revision_is_visible_in_list():
+def test_revision_is_visible_in_list(django_user_model):
     alpha = Dragonfly.objects.create(name="alpha", age=47)
 
-    user = get_user_model().objects.create()
     request = RequestFactory().get("/", {})
-    request.user = user
+    request.user = user_with_perms(django_user_model, ["testapp.view_dragonfly"])
 
     with VersionedDragonflyViewSet().create_revision(request):
         set_comment("number one")
@@ -156,13 +158,31 @@ def test_revision_is_visible_in_list():
 
 
 @mark.django_db
-def test_show_detail_from_previous_version():
+def test_version_list_requires_view_permission(django_user_model):
+    alpha = Dragonfly.objects.create(name="alpha", age=47)
+
+    request = RequestFactory().get("/", {})
+    request.user = user_with_perms(django_user_model, [])
+
+    with VersionedDragonflyViewSet().create_revision(request):
+        set_comment("number one")
+        alpha.save()
+
+    view = VersionedDragonflyViewSet()._get_view(
+        VersionedDragonflyViewSet().components["version_list"]
+    )
+
+    with pytest.raises(PermissionDenied):
+        view(request, pk=alpha.pk)
+
+
+@mark.django_db
+def test_show_detail_from_previous_version(django_user_model):
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     sighting = Sighting.objects.create(name="Berlin", dragonfly=alpha)
 
-    user = get_user_model().objects.create()
     request = RequestFactory().get("/", {})
-    request.user = user
+    request.user = user_with_perms(django_user_model, ["testapp.view_dragonfly"])
 
     with VersionedDragonflyViewSet().create_revision(request):
         alpha.save()
@@ -201,13 +221,33 @@ def test_show_detail_from_previous_version():
 
 
 @mark.django_db
-def test_revert_to_previous_version():
+def test_version_detail_requires_view_perm(django_user_model):
+    alpha = Dragonfly.objects.create(name="alpha", age=47)
+    sighting = Sighting.objects.create(name="Berlin", dragonfly=alpha)
+
+    request = RequestFactory().get("/", {})
+    request.user = user_with_perms(django_user_model, [])
+
+    with VersionedDragonflyViewSet().create_revision(request):
+        alpha.save()
+
+    version = Version.objects.get_for_object_reference(Dragonfly, alpha.pk).latest(
+        "revision__created_date"
+    )
+    detail_view = VersionedDragonflyViewSet()._get_view(
+        VersionedDragonflyViewSet().components["detail"]
+    )
+    with pytest.raises(PermissionDenied):
+        detail_view(request, pk=alpha.pk)
+
+
+@mark.django_db
+def test_revert_to_previous_version(django_user_model):
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     sighting = Sighting.objects.create(name="Berlin", dragonfly=alpha)
 
-    user = get_user_model().objects.create()
     request = RequestFactory().post("/", {})
-    request.user = user
+    request.user = user_with_perms(django_user_model, ["testapp.change_dragonfly"])
     SessionMiddleware().process_request(request)
     MessageMiddleware().process_request(request)
 
@@ -225,7 +265,7 @@ def test_revert_to_previous_version():
     sighting.save()
 
     version_view = VersionedDragonflyViewSet()._get_view(
-        VersionedDragonflyViewSet().components["version_detail"]
+        VersionedDragonflyViewSet().components["version_restore"]
     )
     response = version_view(request, pk=alpha.pk, version_id=version.pk)
 
@@ -236,3 +276,25 @@ def test_revert_to_previous_version():
 
     assert alpha.name == "alpha"
     assert sighting.name == "Berlin"
+
+
+@mark.django_db
+def test_revert_to_previous_version_requires_change_perm(django_user_model):
+    alpha = Dragonfly.objects.create(name="alpha", age=47)
+    sighting = Sighting.objects.create(name="Berlin", dragonfly=alpha)
+
+    request = RequestFactory().post("/", {})
+    request.user = user_with_perms(django_user_model, [])
+
+    with VersionedDragonflyViewSet().create_revision(request):
+        alpha.save()
+
+    version = Version.objects.get_for_object_reference(Dragonfly, alpha.pk).latest(
+        "revision__created_date"
+    )
+
+    version_view = VersionedDragonflyViewSet()._get_view(
+        VersionedDragonflyViewSet().components["version_restore"]
+    )
+    with pytest.raises(PermissionDenied):
+        response = version_view(request, pk=alpha.pk, version_id=version.pk)
diff --git a/tests/test_views.py b/tests/test_views.py
index 7e08d59..f55f096 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -1,10 +1,33 @@
+import pytest
+from django.contrib.auth.models import Permission
 from pytest import mark
 from testapp.models import Dragonfly, Sighting, CascadingSighting, ProtectedSighting
 from testapp.views import DragonflyViewSet
 
 
+def user_with_perms(django_user_model, perms, username="foo", password="bar"):
+    user = django_user_model.objects.create_user(username=username, password=password)
+    for perm in perms:
+        app_label, codename = perm.split(".")
+        user.user_permissions.add(
+            Permission.objects.get(content_type__app_label=app_label, codename=codename)
+        )
+    return user
+
+
+@pytest.fixture
+def client_with_perms(client, django_user_model):
+    def get_client_with_perms(*perms):
+        user_with_perms(django_user_model, perms, username="foo", password="bar")
+        client.login(username="foo", password="bar")
+        return client
+
+    return get_client_with_perms
+
+
 @mark.django_db
-def test_list(client):
+def test_list(client_with_perms):
+    client = client_with_perms("testapp.view_dragonfly")
     Dragonfly.objects.create(name="alpha", age=12)
     Dragonfly.objects.create(name="omega", age=99)
     response = client.get(DragonflyViewSet().links["list"].reverse())
@@ -13,7 +36,16 @@ def test_list(client):
 
 
 @mark.django_db
-def test_list_search(client):
+def test_list_requires_permission(client_with_perms):
+    client = client_with_perms()
+    Dragonfly.objects.create(name="alpha", age=12)
+    response = client.get(DragonflyViewSet().links["list"].reverse())
+    assert response.status_code == 403
+
+
+@mark.django_db
+def test_list_search(client_with_perms):
+    client = client_with_perms("testapp.view_dragonfly")
     Dragonfly.objects.create(name="alpha", age=12)
     Dragonfly.objects.create(name="omega", age=99)
     response = client.get(DragonflyViewSet().links["list"].reverse() + "?q=alpha")
@@ -22,7 +54,8 @@ def test_list_search(client):
 
 
 @mark.django_db
-def test_list_sort(client):
+def test_list_sort(client_with_perms):
+    client = client_with_perms("testapp.view_dragonfly")
     Dragonfly.objects.create(name="alpha", age=12)
     Dragonfly.objects.create(name="omega", age=99)
 
@@ -34,7 +67,10 @@ def test_list_sort(client):
 
 
 @mark.django_db
-def test_detail(client):
+def test_detail(client_with_perms):
+    client = client_with_perms(
+        "testapp.view_dragonfly", "testapp.change_dragonfly", "testapp.delete_dragonfly"
+    )
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     Sighting.objects.create(name="Berlin", dragonfly=alpha)
     Sighting.objects.create(name="Paris", dragonfly=alpha)
@@ -56,7 +92,17 @@ def test_detail(client):
 
 
 @mark.django_db
-def test_update(client):
+def test_detail_requires_permission(client_with_perms):
+    client = client_with_perms()
+    alpha = Dragonfly.objects.create(name="alpha", age=47)
+    links = DragonflyViewSet().links
+    response = client.get(links["detail"].reverse(alpha))
+    assert response.status_code == 403
+
+
+@mark.django_db
+def test_update(client_with_perms):
+    client = client_with_perms("testapp.change_dragonfly")
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     response = client.get(DragonflyViewSet().links["update"].reverse(alpha))
     assert b"alpha" in response.content
@@ -65,7 +111,16 @@ def test_update(client):
 
 
 @mark.django_db
-def test_delete(client):
+def test_update_requires_permission(client_with_perms):
+    client = client_with_perms()
+    alpha = Dragonfly.objects.create(name="alpha", age=47)
+    response = client.get(DragonflyViewSet().links["update"].reverse(alpha))
+    assert response.status_code == 403
+
+
+@mark.django_db
+def test_delete(client_with_perms):
+    client = client_with_perms("testapp.delete_dragonfly")
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     delete_url = DragonflyViewSet().links["delete"].reverse(alpha)
     response = client.get(delete_url)
@@ -81,7 +136,8 @@ def test_delete(client):
 
 
 @mark.django_db
-def test_delete_shows_related(client):
+def test_delete_shows_related(client_with_perms):
+    client = client_with_perms("testapp.delete_dragonfly")
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     CascadingSighting.objects.create(dragonfly=alpha, name="A related sighting")
 
@@ -100,7 +156,8 @@ def test_delete_shows_related(client):
 
 
 @mark.django_db
-def test_delete_protected_not_allowed(client):
+def test_delete_protected_not_allowed(client_with_perms):
+    client = client_with_perms("testapp.delete_dragonfly")
     alpha = Dragonfly.objects.create(name="alpha", age=47)
     ProtectedSighting.objects.create(dragonfly=alpha, name="A related sighting")
 
@@ -119,7 +176,21 @@ def test_delete_protected_not_allowed(client):
 
 
 @mark.django_db
-def test_create_with_inlines(client):
+def test_delete_requires_permission(client_with_perms):
+    client = client_with_perms()
+    alpha = Dragonfly.objects.create(name="alpha", age=47)
+    delete_url = DragonflyViewSet().links["delete"].reverse(alpha)
+
+    response = client.get(delete_url)
+    assert response.status_code == 403
+
+    response = client.post(delete_url)
+    assert response.status_code == 403
+
+
+@mark.django_db
+def test_create_with_inlines(client_with_perms):
+    client = client_with_perms("testapp.add_dragonfly")
     response = client.post(
         "/dragonfly/create/",
         {
@@ -143,7 +214,8 @@ def test_create_with_inlines(client):
 
 
 @mark.django_db
-def test_only_popup_param_is_preserved_in_detail_links(client):
+def test_only_popup_param_is_preserved_in_detail_links(client_with_perms):
+    client = client_with_perms("testapp.view_dragonfly", "testapp.change_dragonfly")
     instance = Dragonfly.objects.create(name="alpha", age=12)
     response = client.get(
         DragonflyViewSet().links["list"].reverse(),