Browse Source

Fixed #18763 -- Added ModelBackend/UserManager.with_perm() methods.

Co-authored-by: Nick Pope <nick.pope@flightdataservices.com>
Berker Peksag 8 years ago
parent
commit
400ec5125e

+ 37 - 0
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)

+ 26 - 0
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):

+ 41 - 0
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
+        ``"<app label>.<permission codename>"`` 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 ``"<app label>.<permission codename>"`` 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

+ 3 - 0
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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 4 - 3
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

+ 137 - 0
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