From 400ec5125ec32e3b18d267bbb4f3aab09d741ce4 Mon Sep 17 00:00:00 2001 From: Berker Peksag Date: Thu, 25 Aug 2016 19:26:18 +0300 Subject: [PATCH] Fixed #18763 -- Added ModelBackend/UserManager.with_perm() methods. Co-authored-by: Nick Pope --- django/contrib/auth/backends.py | 37 +++++++++ django/contrib/auth/models.py | 26 ++++++ docs/ref/contrib/auth.txt | 41 +++++++++ docs/releases/3.0.txt | 3 + docs/topics/auth/customizing.txt | 7 +- tests/auth_tests/test_models.py | 137 +++++++++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 3 deletions(-) diff --git a/django/contrib/auth/backends.py b/django/contrib/auth/backends.py index a3765ae0f1..559d06fe33 100644 --- a/django/contrib/auth/backends.py +++ b/django/contrib/auth/backends.py @@ -3,6 +3,7 @@ import warnings from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.db.models import Exists, OuterRef, Q from django.utils.deprecation import RemovedInDjango31Warning UserModel = get_user_model() @@ -119,6 +120,42 @@ class ModelBackend(BaseBackend): for perm in self.get_all_permissions(user_obj) ) + def with_perm(self, perm, is_active=True, include_superusers=True, obj=None): + """ + Return users that have permission "perm". By default, filter out + inactive users and include superusers. + """ + if isinstance(perm, str): + try: + app_label, codename = perm.split('.') + except ValueError: + raise ValueError( + 'Permission name should be in the form ' + 'app_label.permission_codename.' + ) + elif not isinstance(perm, Permission): + raise TypeError( + 'The `perm` argument must be a string or a permission instance.' + ) + + UserModel = get_user_model() + if obj is not None: + return UserModel._default_manager.none() + + permission_q = Q(group__user=OuterRef('pk')) | Q(user=OuterRef('pk')) + if isinstance(perm, Permission): + permission_q &= Q(pk=perm.pk) + else: + permission_q &= Q(codename=codename, content_type__app_label=app_label) + + user_q = Exists(Permission.objects.filter(permission_q)) + if include_superusers: + user_q |= Q(is_superuser=True) + if is_active is not None: + user_q &= Q(is_active=is_active) + + return UserModel._default_manager.filter(user_q) + def get_user(self, user_id): try: user = UserModel._default_manager.get(pk=user_id) diff --git a/django/contrib/auth/models.py b/django/contrib/auth/models.py index 971621d9c7..27278af4d2 100644 --- a/django/contrib/auth/models.py +++ b/django/contrib/auth/models.py @@ -157,6 +157,32 @@ class UserManager(BaseUserManager): return self._create_user(username, email, password, **extra_fields) + def with_perm(self, perm, is_active=True, include_superusers=True, backend=None, obj=None): + if backend is None: + backends = auth._get_backends(return_tuples=True) + if len(backends) == 1: + backend, _ = backends[0] + else: + raise ValueError( + 'You have multiple authentication backends configured and ' + 'therefore must provide the `backend` argument.' + ) + elif not isinstance(backend, str): + raise TypeError( + 'backend must be a dotted import path string (got %r).' + % backend + ) + else: + backend = auth.load_backend(backend) + if hasattr(backend, 'with_perm'): + return backend.with_perm( + perm, + is_active=is_active, + include_superusers=include_superusers, + obj=obj, + ) + return self.none() + # A few helper functions for common logic between User and AnonymousUser. def _user_get_permissions(user, obj, from_name): diff --git a/docs/ref/contrib/auth.txt b/docs/ref/contrib/auth.txt index 666208b28c..75202ebe4b 100644 --- a/docs/ref/contrib/auth.txt +++ b/docs/ref/contrib/auth.txt @@ -291,6 +291,28 @@ Manager methods The ``email`` and ``password`` parameters were made optional. + .. method:: with_perm(perm, is_active=True, include_superusers=True, backend=None, obj=None) + + .. versionadded:: 3.0 + + Returns users that have the given permission ``perm`` either in the + ``"."`` format or as a + :class:`~django.contrib.auth.models.Permission` instance. Returns an + empty queryset if no users who have the ``perm`` found. + + If ``is_active`` is ``True`` (default), returns only active users, or + if ``False``, returns only inactive users. Use ``None`` to return all + users irrespective of active state. + + If ``include_superusers`` is ``True`` (default), the result will + include superusers. + + If ``backend`` is passed in and it's defined in + :setting:`AUTHENTICATION_BACKENDS`, then this method will use it. + Otherwise, it will use the ``backend`` in + :setting:`AUTHENTICATION_BACKENDS`, if there is only one, or raise an + exception. + ``AnonymousUser`` object ======================== @@ -520,6 +542,9 @@ The following backends are available in :mod:`django.contrib.auth.backends`: implement them other than returning an empty set of permissions if ``obj is not None``. + :meth:`with_perm` also allows an object to be passed as a parameter, but + unlike others methods it returns an empty queryset if ``obj is not None``. + .. method:: authenticate(request, username=None, password=None, **kwargs) Tries to authenticate ``username`` with ``password`` by calling @@ -577,6 +602,22 @@ The following backends are available in :mod:`django.contrib.auth.backends`: don't have an :attr:`~django.contrib.auth.models.CustomUser.is_active` field are allowed. + .. method:: with_perm(perm, is_active=True, include_superusers=True, obj=None) + + .. versionadded:: 3.0 + + Returns all active users who have the permission ``perm`` either in + the form of ``"."`` or a + :class:`~django.contrib.auth.models.Permission` instance. Returns an + empty queryset if no users who have the ``perm`` found. + + If ``is_active`` is ``True`` (default), returns only active users, or + if ``False``, returns only inactive users. Use ``None`` to return all + users irrespective of active state. + + If ``include_superusers`` is ``True`` (default), the result will + include superusers. + .. class:: AllowAllUsersModelBackend Same as :class:`ModelBackend` except that it doesn't reject inactive users diff --git a/docs/releases/3.0.txt b/docs/releases/3.0.txt index f6ec8f8cc7..7a9adb3b39 100644 --- a/docs/releases/3.0.txt +++ b/docs/releases/3.0.txt @@ -128,6 +128,9 @@ Minor features * :attr:`~django.contrib.auth.models.CustomUser.REQUIRED_FIELDS` now supports :class:`~django.db.models.ManyToManyField`\s. +* The new :meth:`.UserManager.with_perm` method returns users that have the + specified permission. + :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/auth/customizing.txt b/docs/topics/auth/customizing.txt index d2a4746e03..4c9515fe53 100644 --- a/docs/topics/auth/customizing.txt +++ b/docs/topics/auth/customizing.txt @@ -179,12 +179,13 @@ Handling authorization in custom backends Custom auth backends can provide their own permissions. -The user model will delegate permission lookup functions +The user model and its manager will delegate permission lookup functions (:meth:`~django.contrib.auth.models.User.get_user_permissions()`, :meth:`~django.contrib.auth.models.User.get_group_permissions()`, :meth:`~django.contrib.auth.models.User.get_all_permissions()`, -:meth:`~django.contrib.auth.models.User.has_perm()`, and -:meth:`~django.contrib.auth.models.User.has_module_perms()`) to any +:meth:`~django.contrib.auth.models.User.has_perm()`, +:meth:`~django.contrib.auth.models.User.has_module_perms()`, and +:meth:`~django.contrib.auth.models.UserManager.with_perm()`) to any authentication backend that implements these functions. The permissions given to the user will be the superset of all permissions diff --git a/tests/auth_tests/test_models.py b/tests/auth_tests/test_models.py index a3502a224f..c60b66c993 100644 --- a/tests/auth_tests/test_models.py +++ b/tests/auth_tests/test_models.py @@ -2,6 +2,7 @@ from unittest import mock from django.conf.global_settings import PASSWORD_HASHERS from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.hashers import get_hasher from django.contrib.auth.models import ( @@ -261,6 +262,142 @@ class AbstractUserTestCase(TestCase): hasher.iterations = old_iterations +class CustomModelBackend(ModelBackend): + def with_perm(self, perm, is_active=True, include_superusers=True, backend=None, obj=None): + if obj is not None and obj.username == 'charliebrown': + return User.objects.filter(pk=obj.pk) + return User.objects.filter(username__startswith='charlie') + + +class UserWithPermTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + content_type = ContentType.objects.get_for_model(Group) + cls.permission = Permission.objects.create( + name='test', content_type=content_type, codename='test', + ) + # User with permission. + cls.user1 = User.objects.create_user('user 1', 'foo@example.com') + cls.user1.user_permissions.add(cls.permission) + # User with group permission. + group1 = Group.objects.create(name='group 1') + group1.permissions.add(cls.permission) + group2 = Group.objects.create(name='group 2') + group2.permissions.add(cls.permission) + cls.user2 = User.objects.create_user('user 2', 'bar@example.com') + cls.user2.groups.add(group1, group2) + # Users without permissions. + cls.user_charlie = User.objects.create_user('charlie', 'charlie@example.com') + cls.user_charlie_b = User.objects.create_user('charliebrown', 'charlie@brown.com') + # Superuser. + cls.superuser = User.objects.create_superuser( + 'superuser', 'superuser@example.com', 'superpassword', + ) + # Inactive user with permission. + cls.inactive_user = User.objects.create_user( + 'inactive_user', 'baz@example.com', is_active=False, + ) + cls.inactive_user.user_permissions.add(cls.permission) + + def test_invalid_permission_name(self): + msg = 'Permission name should be in the form app_label.permission_codename.' + for perm in ('nodots', 'too.many.dots', '...', ''): + with self.subTest(perm), self.assertRaisesMessage(ValueError, msg): + User.objects.with_perm(perm) + + def test_invalid_permission_type(self): + msg = 'The `perm` argument must be a string or a permission instance.' + for perm in (b'auth.test', object(), None): + with self.subTest(perm), self.assertRaisesMessage(TypeError, msg): + User.objects.with_perm(perm) + + def test_invalid_backend_type(self): + msg = 'backend must be a dotted import path string (got %r).' + for backend in (b'auth_tests.CustomModelBackend', object()): + with self.subTest(backend): + with self.assertRaisesMessage(TypeError, msg % backend): + User.objects.with_perm('auth.test', backend=backend) + + def test_basic(self): + active_users = [self.user1, self.user2] + tests = [ + ({}, [*active_users, self.superuser]), + ({'obj': self.user1}, []), + # Only inactive users. + ({'is_active': False}, [self.inactive_user]), + # All users. + ({'is_active': None}, [*active_users, self.superuser, self.inactive_user]), + # Exclude superusers. + ({'include_superusers': False}, active_users), + ( + {'include_superusers': False, 'is_active': False}, + [self.inactive_user], + ), + ( + {'include_superusers': False, 'is_active': None}, + [*active_users, self.inactive_user], + ), + ] + for kwargs, expected_users in tests: + for perm in ('auth.test', self.permission): + with self.subTest(perm=perm, **kwargs): + self.assertCountEqual( + User.objects.with_perm(perm, **kwargs), + expected_users, + ) + + @override_settings(AUTHENTICATION_BACKENDS=['django.contrib.auth.backends.BaseBackend']) + def test_backend_without_with_perm(self): + self.assertSequenceEqual(User.objects.with_perm('auth.test'), []) + + def test_nonexistent_permission(self): + self.assertSequenceEqual(User.objects.with_perm('auth.perm'), [self.superuser]) + + def test_nonexistent_backend(self): + with self.assertRaises(ImportError): + User.objects.with_perm( + 'auth.test', + backend='invalid.backend.CustomModelBackend', + ) + + @override_settings(AUTHENTICATION_BACKENDS=['auth_tests.test_models.CustomModelBackend']) + def test_custom_backend(self): + for perm in ('auth.test', self.permission): + with self.subTest(perm): + self.assertCountEqual( + User.objects.with_perm(perm), + [self.user_charlie, self.user_charlie_b], + ) + + @override_settings(AUTHENTICATION_BACKENDS=['auth_tests.test_models.CustomModelBackend']) + def test_custom_backend_pass_obj(self): + for perm in ('auth.test', self.permission): + with self.subTest(perm): + self.assertSequenceEqual( + User.objects.with_perm(perm, obj=self.user_charlie_b), + [self.user_charlie_b], + ) + + @override_settings(AUTHENTICATION_BACKENDS=[ + 'auth_tests.test_models.CustomModelBackend', + 'django.contrib.auth.backends.ModelBackend', + ]) + def test_multiple_backends(self): + msg = ( + 'You have multiple authentication backends configured and ' + 'therefore must provide the `backend` argument.' + ) + with self.assertRaisesMessage(ValueError, msg): + User.objects.with_perm('auth.test') + + backend = 'auth_tests.test_models.CustomModelBackend' + self.assertCountEqual( + User.objects.with_perm('auth.test', backend=backend), + [self.user_charlie, self.user_charlie_b], + ) + + class IsActiveTestCase(TestCase): """ Tests the behavior of the guaranteed is_active attribute