Skip to content

feat: Performant permission calculation for pages #7943

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cms/admin/pageadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,13 +646,14 @@ def get_permissions(self, request, page_id):
)

if not can_change_global_permissions:
allowed_pages = frozenset(page_permissions.get_change_id_list(user, site, check_global=False))
allowed_pages = page_permissions.get_change_perm_tuples(user, site, check_global=False)

for permission in _page_permissions.iterator():
if can_change_global_permissions:
can_change = True
else:
can_change = permission.page_id in allowed_pages
page_path = permission.page.node.path
can_change = any(perm_tuple.contains(page_path) for perm_tuple in allowed_pages)

row = PermissionRow(
is_global=False,
Expand Down Expand Up @@ -1048,7 +1049,7 @@ def has_change_permission(self, request, obj=None):
return page_permissions.user_can_change_page(request.user, page=obj.page, site=site)
can_change_page = page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=get_site(request),
site=site,
use_cache=False,
)
return can_change_page
Expand Down
57 changes: 30 additions & 27 deletions cms/cms_menus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@
from typing import Optional

from django.db.models.query import Prefetch, prefetch_related_objects
from django.urls import reverse
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override as force_language

from cms import constants
from cms.apphook_pool import apphook_pool
from cms.models import EmptyPageContent, PageContent, PageUrl
from cms.models import EmptyPageContent, PageContent, PagePermission, PageUrl
from cms.toolbar.utils import get_object_preview_url, get_toolbar_from_request
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import (
Expand All @@ -19,7 +17,6 @@
)
from cms.utils.page import get_page_queryset
from cms.utils.page_permissions import user_can_view_all_pages
from cms.utils.permissions import get_view_restrictions
from menus.base import Menu, Modifier, NavigationNode
from menus.menu_pool import menu_pool

Expand All @@ -29,45 +26,51 @@ def get_visible_nodes(request, pages, site):
This code is a many-pages-at-once version of cms.utils.page_permissions.user_can_view_page.
`pages` contains all published pages.
"""
user = request.user
public_for = get_cms_setting("PUBLIC_FOR")
can_see_unrestricted = public_for == "all" or (public_for == "staff" and user.is_staff)
can_see_unrestricted = public_for == "all" or (public_for == "staff" and request.user.is_staff)

if not user.is_authenticated and not can_see_unrestricted:
if not request.user.is_authenticated and not can_see_unrestricted:
# User is not authenticated and can't see unrestricted pages,
# no need to check for page restrictions because if there's some,
# user is anon and if there is not any, user can't see unrestricted.
return []

if user_can_view_all_pages(user, site):
if user_can_view_all_pages(request.user, site):
return list(pages)

restricted_pages = get_view_restrictions(pages)

if not restricted_pages:
if not get_cms_setting('PERMISSION'):
# If there's no restrictions, let the user see all pages
# only if he can see unrestricted, otherwise return no pages.
return list(pages) if can_see_unrestricted else []

user_id = user.pk
user_groups = SimpleLazyObject(lambda: frozenset(user.groups.values_list("pk", flat=True)))
is_auth_user = user.is_authenticated

def user_can_see_page(page):
page_permissions = restricted_pages.get(page.pk, [])
restrictions = PagePermission.objects.filter(
page__in=pages,
can_view=True,
)
restriction_map = {perm.page_id: perm for perm in restrictions}

if not page_permissions:
# Page has no view restrictions, fallback to the project's
# CMS_PUBLIC_FOR setting.
return can_see_unrestricted
user_id = request.user.pk
user_groups = SimpleLazyObject(lambda: frozenset(request.user.groups.values_list("pk", flat=True)))
is_auth_user = request.user.is_authenticated

if not is_auth_user:
return False
def user_can_see_page(page):
if page.pk in restriction_map:
# set internal fk cache to our page with loaded ancestors and descendants
PagePermission.page.field.set_cached_value(restriction_map[page.pk], page)

restricted = False
for perm in restrictions:
if perm.get_page_permission_tuple().contains(page.node.path):
if not is_auth_user:
return False
if perm.user_id == user_id or perm.group_id in user_groups:
return True
restricted = True

# Page has no view restrictions, fallback to the project's
# CMS_PUBLIC_FOR setting.
return can_see_unrestricted and not restricted

for perm in page_permissions:
if perm.user_id == user_id or perm.group_id in user_groups:
return True
return False
return [page for page in pages if user_can_see_page(page)]


Expand Down
2 changes: 1 addition & 1 deletion cms/menu_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


class CMSAttachMenu(Menu):
"""Base class that can be subclassed to allow your app to attach its oqn menus."""
"""Base class that can be subclassed to allow your app to attach its own menus."""
cms_enabled = True
instance = None
name = None
Expand Down
13 changes: 8 additions & 5 deletions cms/models/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,16 @@ def subordinate_to_user(self, user, site):
users C,X,D,Y,I,J but not A, because A user in higher in hierarchy.

If permission object holds group, this permission object can be visible
to user only if all of the group members are lover in hierarchy. If any
of members is higher then given user, this entry must stay invisible.
to user only if all the group members are lover in hierarchy. If any
of members is higher than given user, this entry must stay invisible.

If user is superuser, or haves global can_change_permission permissions,
show him everything.

Result of this is used in admin for page permissions inline.
"""
# get user level
from cms.utils.page_permissions import get_change_permissions_id_list
from cms.utils.page_permissions import get_change_permissions_perm_tuples
from cms.utils.permissions import get_user_permission_level

try:
Expand All @@ -307,12 +307,15 @@ def subordinate_to_user(self, user, site):
return self.all()

# get all permissions
page_id_allow_list = get_change_permissions_id_list(user, site, check_global=False)
from cms.models import PermissionTuple
allow_list = Q()
for perm_tuple in get_change_permissions_perm_tuples(user, site, check_global=False):
allow_list |= PermissionTuple(perm_tuple).allow_list("page__node")

# get permission set, but without objects targeting user, or any group
# in which he can be
qs = self.filter(
page__id__in=page_id_allow_list,
allow_list,
page__node__depth__gte=user_level,
)
qs = qs.exclude(user=user).exclude(group__user=user)
Expand Down
50 changes: 48 additions & 2 deletions cms/models/permissionmodels.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import models
from django.db.models import Q
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _

from cms.models import Page
from cms.models import Page, TreeNode
from cms.models.managers import (
GlobalPagePermissionManager,
PagePermissionManager,
Expand Down Expand Up @@ -214,6 +215,38 @@ def __str__(self):
return "%s :: GLOBAL" % self.audience


class PermissionTuple(tuple):
def contains(self, path: str, steplen: int = TreeNode.steplen) -> bool:
grant_on, perm_path = self
if grant_on == ACCESS_PAGE:
return path == perm_path
elif grant_on == ACCESS_CHILDREN:
return path.startswith(perm_path) and len(path) == len(perm_path) + steplen
elif grant_on == ACCESS_DESCENDANTS:
return path.startswith(perm_path) and len(path) > len(perm_path)
elif grant_on == ACCESS_PAGE_AND_DESCENDANTS:
return path.startswith(perm_path)
elif grant_on == ACCESS_PAGE_AND_CHILDREN:
return path.startswith(perm_path) and len(path) <= len(perm_path) + steplen
return False

def allow_list(self, filter: str = "", steplen: int = TreeNode.steplen) -> Q:
if filter !="":
filter = f"{filter}__"
grant_on, path = self
if grant_on == ACCESS_PAGE:
return Q(**{f"{filter}path": path})
elif grant_on == ACCESS_CHILDREN:
return Q(**{f"{filter}path__startswith": path, f"{filter}__path__length": len(path) + steplen})
elif grant_on == ACCESS_DESCENDANTS:
return Q(**{f"{filter}path__startswith": path, f"{filter}__path__length__gt": len(path)})
elif grant_on == ACCESS_PAGE_AND_DESCENDANTS:
return Q(**{f"{filter}path__startswith": path})
elif grant_on == ACCESS_PAGE_AND_CHILDREN:
return Q(**{f"{filter}path__startswith": path, f"{filter}__path__length__lte": len(path) + steplen})
return Q()


class PagePermission(AbstractPagePermission):
"""Page permissions for a single page
"""
Expand All @@ -236,14 +269,27 @@ def clean(self):

if self.can_add and self.grant_on == ACCESS_PAGE:
# this is a misconfiguration - user can add/move page to current
# page but after he does this, he will not have permissions to
# page, but after he does this, he will not have permissions to
# access this page anymore, so avoid this.
message = _("Add page permission requires also access to children, "
"or descendants, otherwise added page can't be changed "
"by its creator.")
raise ValidationError(message)

def get_page_permission_tuple(self):
node = self.page.node
return PermissionTuple((self.grant_on, node.path))

def get_page_ids(self):
import warnings

from cms.utils.compat.warnings import RemovedInDjangoCMS43Warning
warnings.warn("get_page_ids is deprecated and will be removed in django CMS 4.3, "
"use get_page_permission_tuple instead", RemovedInDjangoCMS43Warning, stacklevel=2)

return self._get_page_ids()

def _get_page_ids(self):
if self.grant_on & MASK_PAGE:
yield self.page_id

Expand Down
4 changes: 2 additions & 2 deletions cms/templates/admin/cms/page/tree/menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
{% get_admin_url_for_language page preview_language as content_admin_url %}
<div{% if has_change_permission and has_change_advanced_settings_permission and content_admin_url %} class="cms-hover-tooltip cms-hover-tooltip-left cms-hover-tooltip-delay"
data-cms-tooltip="{% trans "Page settings (SHIFT click for advanced settings)" %}"{% endif %}>
{% if content_admin_url and page_content %}
{% if has_change_permission and page_content and content_admin_url %}
<a href="{{ content_admin_url }}"
class="cms-btn cms-btn-default js-cms-tree-advanced-settings cms-icon cms-icon-settings"
{% if has_change_advanced_settings_permission %}
Expand All @@ -149,7 +149,7 @@
<span class="cms-btn cms-btn-default cms-btn-disabled js-cms-tree-advanced-settings cms-icon cms-icon-settings">
{% endif %}
<span class="sr-only">{% trans "Page settings (SHIFT click for advanced settings)" %}</span>
{% if has_change_permission and content_admin_url or has_change_advanced_settings_permission %}
{% if has_change_permission and page_content and content_admin_url %}
</a>
{% endif %}
</div>
Expand Down
10 changes: 5 additions & 5 deletions cms/tests/test_page_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1948,7 +1948,7 @@ def test_user_cant_delete_non_empty_page(self):
self._add_plugin_to_page(page)

self.add_permission(staff_user, 'change_page')
self.add_permission(staff_user, 'delete_page')
self.remove_permission(staff_user, 'delete_page')
gp = self.add_global_permission(staff_user, can_change=True, can_delete=True)

with self.login_user_context(staff_user):
Expand Down Expand Up @@ -2066,7 +2066,7 @@ def test_user_cant_delete_non_empty_translation(self):
self._add_plugin_to_page(page, language=translation.language)

self.add_permission(staff_user, 'change_page')
self.add_permission(staff_user, 'delete_page')
self.remove_permission(staff_user, 'delete_page')
self.add_global_permission(staff_user, can_change=True, can_delete=True)

with self.login_user_context(staff_user):
Expand Down Expand Up @@ -3410,8 +3410,8 @@ def test_user_cant_delete_non_empty_page(self):
page_perm = self.add_page_permission(
staff_user,
page,
can_change=True,
can_delete=True,
can_change=False,
can_delete=False,
)

with self.login_user_context(staff_user):
Expand Down Expand Up @@ -3548,7 +3548,7 @@ def test_user_cant_delete_non_empty_translation(self):
self.add_page_permission(
staff_user,
page,
can_change=True,
can_change=False,
can_delete=True,
)

Expand Down
8 changes: 4 additions & 4 deletions cms/tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
get_permission_cache,
set_permission_cache,
)
from cms.models.permissionmodels import GlobalPagePermission
from cms.models.permissionmodels import ACCESS_PAGE_AND_DESCENDANTS, GlobalPagePermission
from cms.test_utils.testcases import CMSTestCase
from cms.utils.page_permissions import (
get_change_id_list,
get_change_perm_tuples,
user_can_publish_page,
)

Expand Down Expand Up @@ -59,10 +59,10 @@ def test_permission_manager(self):
cached_permissions = get_permission_cache(self.user_normal, "change_page")
self.assertIsNone(cached_permissions)

live_permissions = get_change_id_list(self.user_normal, Site.objects.get_current())
live_permissions = get_change_perm_tuples(self.user_normal, Site.objects.get_current())
cached_permissions_permissions = get_permission_cache(self.user_normal,
"change_page")
self.assertEqual(live_permissions, [page_b.id])
self.assertEqual(live_permissions, [(ACCESS_PAGE_AND_DESCENDANTS, page_b.node.path)])
self.assertEqual(cached_permissions_permissions, live_permissions)

def test_cached_permission_precedence(self):
Expand Down
9 changes: 3 additions & 6 deletions cms/tests/test_permmod.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,13 +488,12 @@ def test_page_permissions(self):
request = self.get_request(user)
PagePermission.objects.create(can_view=True, user=user, page=self.page, grant_on=ACCESS_PAGE)

with self.assertNumQueries(6):
with self.assertNumQueries(5):
"""
The queries are:
PagePermission query (is this page restricted)
content type lookup (x2)
GlobalpagePermission query for user
TreeNode lookup
PagePermission query for this user
"""
self.assertViewAllowed(self.page, user)
Expand All @@ -506,13 +505,12 @@ def test_page_group_permissions(self):
user.groups.add(self.group)
request = self.get_request(user)

with self.assertNumQueries(6):
with self.assertNumQueries(5):
"""
The queries are:
PagePermission query (is this page restricted)
content type lookup (x2)
GlobalpagePermission query for user
TreeNode lookup
PagePermission query for user
"""
self.assertViewAllowed(self.page, user)
Expand Down Expand Up @@ -540,13 +538,12 @@ def test_basic_perm_denied(self):
user = self.get_staff_user_with_no_permissions()
request = self.get_request(user)

with self.assertNumQueries(6):
with self.assertNumQueries(5):
"""
The queries are:
PagePermission query (is this page restricted)
content type lookup x2
GlobalpagePermission query for user
TreeNode lookup
PagePermission query for this user
"""
self.assertViewNotAllowed(self.page, user)
Expand Down
2 changes: 1 addition & 1 deletion cms/utils/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def send_mail(subject, txt_template, to, context=None, html_template=None, fail_

context = context or {}
context.update({
'login_url': "http://%s" % urljoin(site.domain, admin_reverse('index')),
'login_url': "https://%s" % urljoin(site.domain, admin_reverse('index')),
'title': subject,
})

Expand Down
Loading
Loading