Skip to content

Commit

Permalink
Fixed django#17905 -- Restricted access to model pages in admindocs.
Browse files Browse the repository at this point in the history
Only users with view or change model permissions can access.
Thank you to Sarah Boyce for the review.
  • Loading branch information
sai-ganesh-03 authored and sarahboyce committed Nov 11, 2024
1 parent ef8ae06 commit c12bc98
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 7 deletions.
24 changes: 22 additions & 2 deletions django/contrib/admindocs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
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
Expand Down Expand Up @@ -202,11 +207,24 @@ def get_context_data(self, **kwargs):
)


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


Expand All @@ -228,6 +246,8 @@ def get_context_data(self, **kwargs):
)

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)
Expand Down
16 changes: 12 additions & 4 deletions docs/ref/contrib/admin/admindocs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand All @@ -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
==============

Expand Down
4 changes: 3 additions & 1 deletion docs/releases/5.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ Minor features
* Links to components in docstrings now supports custom link text, using the
format ``:role:`link text <link>```. See :ref:`documentation helpers
<admindocs-helpers>` for more details.


* The :ref:`model pages <admindocs-model-reference>` are now restricted to only
allow access to users with the corresponding model view or change permissions.

:mod:`django.contrib.auth`
~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
106 changes: 106 additions & 0 deletions tests/admin_docs/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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
Expand Down Expand Up @@ -482,6 +484,110 @@ def test_model_not_found(self):
)
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,
'<a href="/admindocs/models/admin_docs.family/">Family</a>',
html=True,
)
self.assertContains(
response,
'<a href="/admindocs/models/admin_docs.person/">Person</a>',
html=True,
)
self.assertContains(
response,
'<a href="/admindocs/models/admin_docs.company/">Company</a>',
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,
'<a href="/admindocs/models/admin_docs.family/">Family</a>',
html=True,
)
self.assertNotContains(
response,
'<a href="/admindocs/models/admin_docs.person/">Person</a>',
html=True,
)
self.assertNotContains(
response,
'<a href="/admindocs/models/admin_docs.company/">Company</a>',
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,
'<a href="/admindocs/models/admin_docs.family/">Family</a>',
html=True,
)
self.assertContains(
response,
'<a href="/admindocs/models/admin_docs.person/">Person</a>',
html=True,
)
self.assertContains(
response,
'<a href="/admindocs/models/admin_docs.company/">Company</a>',
html=True,
)


class CustomField(models.Field):
description = "A custom field type"
Expand Down

0 comments on commit c12bc98

Please sign in to comment.