diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py index 3fdb34e0d1..0c4ece29fe 100644 --- a/django/contrib/admindocs/views.py +++ b/django/contrib/admindocs/views.py @@ -13,7 +13,12 @@ from django.contrib.admindocs.utils import ( replace_named_groups, replace_unnamed_groups, ) -from django.core.exceptions import ImproperlyConfigured, ViewDoesNotExist +from django.contrib.auth import get_permission_codename +from django.core.exceptions import ( + ImproperlyConfigured, + PermissionDenied, + ViewDoesNotExist, +) from django.db import models from django.http import Http404 from django.template.engine import Engine @@ -202,11 +207,24 @@ class ViewDetailView(BaseAdminDocsView): ) +def user_has_model_view_permission(user, opts): + """Based off ModelAdmin.has_view_permission.""" + codename_view = get_permission_codename("view", opts) + codename_change = get_permission_codename("change", opts) + return user.has_perm("%s.%s" % (opts.app_label, codename_view)) or user.has_perm( + "%s.%s" % (opts.app_label, codename_change) + ) + + class ModelIndexView(BaseAdminDocsView): template_name = "admin_doc/model_index.html" def get_context_data(self, **kwargs): - m_list = [m._meta for m in apps.get_models()] + m_list = [ + m._meta + for m in apps.get_models() + if user_has_model_view_permission(self.request.user, m._meta) + ] return super().get_context_data(**{**kwargs, "models": m_list}) @@ -228,6 +246,8 @@ class ModelDetailView(BaseAdminDocsView): ) opts = model._meta + if not user_has_model_view_permission(self.request.user, opts): + raise PermissionDenied title, body, metadata = utils.parse_docstring(model.__doc__) title = title and utils.parse_rst(title, "model", _("model:") + model_name) diff --git a/docs/ref/contrib/admin/admindocs.txt b/docs/ref/contrib/admin/admindocs.txt index 240def8efb..5a605748ad 100644 --- a/docs/ref/contrib/admin/admindocs.txt +++ b/docs/ref/contrib/admin/admindocs.txt @@ -56,13 +56,16 @@ Each of these support custom link text with the format Support for custom link text was added. +.. _admindocs-model-reference: + Model reference =============== -The **models** section of the ``admindocs`` page describes each model in the -system along with all the fields, properties, and methods available on it. -Relationships to other models appear as hyperlinks. Descriptions are pulled -from ``help_text`` attributes on fields or from docstrings on model methods. +The **models** section of the ``admindocs`` page describes each model that the +user has access to along with all the fields, properties, and methods available +on it. Relationships to other models appear as hyperlinks. Descriptions are +pulled from ``help_text`` attributes on fields or from docstrings on model +methods. A model with useful documentation might look like this:: @@ -86,6 +89,11 @@ A model with useful documentation might look like this:: """Makes the blog entry live on the site.""" ... +.. versionchanged:: 5.2 + + Access was restricted to only allow users with model view or change + permissions. + View reference ============== diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index d07e9cb098..88a1daa45d 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -47,7 +47,9 @@ Minor features * Links to components in docstrings now supports custom link text, using the format ``:role:`link text ```. See :ref:`documentation helpers ` for more details. - + +* The :ref:`model pages ` are now restricted to only + allow access to users with the corresponding model view or change permissions. :mod:`django.contrib.auth` ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_docs/test_views.py b/tests/admin_docs/test_views.py index f7232a7e03..11b70d6cd9 100644 --- a/tests/admin_docs/test_views.py +++ b/tests/admin_docs/test_views.py @@ -5,6 +5,8 @@ from django.conf import settings from django.contrib import admin from django.contrib.admindocs import utils, views from django.contrib.admindocs.views import get_return_data_type, simplify_regex +from django.contrib.auth.models import Permission, User +from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site from django.db import models from django.db.models import fields @@ -482,6 +484,110 @@ class TestModelDetailView(TestDataMixin, AdminDocsTestCase): ) self.assertEqual(response.status_code, 404) + def test_model_permission_denied(self): + person_url = reverse( + "django-admindocs-models-detail", args=["admin_docs", "person"] + ) + company_url = reverse( + "django-admindocs-models-detail", args=["admin_docs", "company"] + ) + staff_user = User.objects.create_user( + username="staff", password="secret", is_staff=True + ) + self.client.force_login(staff_user) + response_for_person = self.client.get(person_url) + response_for_company = self.client.get(company_url) + # No access without permissions. + self.assertEqual(response_for_person.status_code, 403) + self.assertEqual(response_for_company.status_code, 403) + company_content_type = ContentType.objects.get_for_model(Company) + person_content_type = ContentType.objects.get_for_model(Person) + view_company = Permission.objects.get( + codename="view_company", content_type=company_content_type + ) + change_person = Permission.objects.get( + codename="change_person", content_type=person_content_type + ) + staff_user.user_permissions.add(view_company, change_person) + response_for_person = self.client.get(person_url) + response_for_company = self.client.get(company_url) + # View or change permission grants access. + self.assertEqual(response_for_person.status_code, 200) + self.assertEqual(response_for_company.status_code, 200) + + +@unittest.skipUnless(utils.docutils_is_available, "no docutils installed.") +class TestModelIndexView(TestDataMixin, AdminDocsTestCase): + def test_model_index_superuser(self): + self.client.force_login(self.superuser) + index_url = reverse("django-admindocs-models-index") + response = self.client.get(index_url) + self.assertContains( + response, + 'Family', + html=True, + ) + self.assertContains( + response, + 'Person', + html=True, + ) + self.assertContains( + response, + 'Company', + html=True, + ) + + def test_model_index_with_model_permission(self): + staff_user = User.objects.create_user( + username="staff", password="secret", is_staff=True + ) + self.client.force_login(staff_user) + index_url = reverse("django-admindocs-models-index") + response = self.client.get(index_url) + # Models are not listed without permissions. + self.assertNotContains( + response, + 'Family', + html=True, + ) + self.assertNotContains( + response, + 'Person', + html=True, + ) + self.assertNotContains( + response, + 'Company', + html=True, + ) + company_content_type = ContentType.objects.get_for_model(Company) + person_content_type = ContentType.objects.get_for_model(Person) + view_company = Permission.objects.get( + codename="view_company", content_type=company_content_type + ) + change_person = Permission.objects.get( + codename="change_person", content_type=person_content_type + ) + staff_user.user_permissions.add(view_company, change_person) + response = self.client.get(index_url) + # View or change permission grants access. + self.assertNotContains( + response, + 'Family', + html=True, + ) + self.assertContains( + response, + 'Person', + html=True, + ) + self.assertContains( + response, + 'Company', + html=True, + ) + class CustomField(models.Field): description = "A custom field type"