Skip to content
Closed
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
31 changes: 31 additions & 0 deletions django/contrib/auth/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.db.models import Q
from django.utils.deprecation import RemovedInDjango31Warning

UserModel = get_user_model()
Expand Down Expand Up @@ -97,6 +98,36 @@ def has_module_perms(self, user_obj, app_label):
for perm in self.get_all_permissions(user_obj)
)

def with_perm(self, perm, is_active=True, is_superuser=True, obj=None):
UserModel = get_user_model()
if not isinstance(perm, (str, Permission)):
raise TypeError('The `perm` argument must be a string or a permission instance.')
elif isinstance(perm, str):
try:
app_label, codename = perm.split('.')
except ValueError:
raise ValueError("Permission name should be in the form 'app_label.perm_name'.") from None

if obj is not None:
return UserModel._default_manager.none()

if isinstance(perm, Permission):
return UserModel._default_manager.filter(
Q(is_active=is_active) & (
Q(is_superuser=is_superuser) |
Q(user_permissions=perm) |
Q(groups__permissions=perm)
)
).distinct()

user_q = Q(user_permissions__codename=codename,
user_permissions__content_type__app_label=app_label)
group_q = Q(groups__permissions__codename=codename,
groups__permissions__content_type__app_label=app_label)
has_permission_q = Q(is_active=is_active) & (Q(is_superuser=is_superuser) | user_q | group_q)

return UserModel._default_manager.filter(has_permission_q).distinct()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make it more DRY and readable, e.g.

         if isinstance(perm, Permission):
            has_permission = Q(user_permissions=perm) | Q(groups__permissions=perm)
        else:
            has_permission = Q(
                Q(user_permissions__codename=codename) &
                Q(user_permissions__content_type__app_label=app_label)
            ) | Q (
                Q(groups__permissions__codename=codename) &
                Q(groups__permissions__content_type__app_label=app_label)
            )
        return UserModel._default_manager.filter(
            Q(is_active=is_active) &
            Q(Q(is_superuser=is_superuser) | has_permission)
        ).distinct()

What do you think?

I wonder if we can use Exists() to avoid distinct() call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, your suggestion definitely looks better. I'll update the PR. Thanks!

Do you mean the queryset.exists() method or does Exists() mean something else?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was talking about Exists() subquery.


def get_user(self, user_id):
try:
user = UserModel._default_manager.get(pk=user_id)
Expand Down
16 changes: 16 additions & 0 deletions django/contrib/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ def create_superuser(self, username, email, password, **extra_fields):

return self._create_user(username, email, password, **extra_fields)

def with_perm(self, perm, is_active=True, is_superuser=True, backend=None, obj=None):
if backend is None:
backends = auth._get_backends(return_tuples=True)
if len(backends) != 1:
raise ValueError(
'You have multiple authentication backends configured and '
'therefore must provide the `backend` argument.'
)
_, backend = backends[0]
elif not isinstance(backend, str):
raise TypeError('The `backend` argument must be a string.')
backend = auth.load_backend(backend)
if hasattr(backend, 'with_perm'):
return backend.with_perm(perm, is_active=is_active, is_superuser=is_superuser, obj=obj)
return self.get_queryset().none()


# A few helper functions for common logic between User and AnonymousUser.
def _user_get_all_permissions(user, obj):
Expand Down
28 changes: 28 additions & 0 deletions docs/ref/contrib/auth.txt
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,21 @@ Manager methods
Same as :meth:`create_user`, but sets :attr:`~models.User.is_staff` and
:attr:`~models.User.is_superuser` to ``True``.

.. method:: with_perm(perm, is_active=True, is_superuser=True, backend=None, obj=None)

.. versionadded:: 3.0

Returns a queryset containing :class:`~django.contrib.auth.models.User`
objects that have the given permission ``perm`` either in the form of
``"<app label>.<permission codename>"`` or a
:class:`~django.contrib.auth.models.Permission` instance, including
superusers (if ``is_superuser`` is ``True``.)

If ``is_active`` is ``True``, all active users will be included.

If ``backend`` is given, it will be used if it's defined in
the :setting:`AUTHENTICATION_BACKENDS` setting.

``AnonymousUser`` object
========================

Expand Down Expand Up @@ -479,6 +494,10 @@ 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 allow an object to be passed as a parameter for
object-specific permissions, but 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
Expand Down Expand Up @@ -536,6 +555,15 @@ 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, is_superuser=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.

.. class:: AllowAllUsersModelBackend

Same as :class:`ModelBackend` except that it doesn't reject inactive users
Expand Down
4 changes: 3 additions & 1 deletion docs/releases/3.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ Minor features
:mod:`django.contrib.auth`
~~~~~~~~~~~~~~~~~~~~~~~~~~

* ...
* The new
:meth:`UserManager.with_perm() <django.contrib.auth.models.UserManager.with_perm>`
method returns all users that have the specified permission.

:mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
5 changes: 3 additions & 2 deletions docs/topics/auth/customizing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,9 @@ Custom auth backends can provide their own permissions.
The user model will delegate permission lookup functions
(: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
Expand Down
3 changes: 2 additions & 1 deletion tests/auth_tests/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
)
from .invalid_models import CustomUserNonUniqueUsername
from .is_active import IsActiveTestUser1
from .minimal import MinimalUser
from .minimal import CustomModel, MinimalUser
from .no_password import NoPasswordUser
from .proxy import Proxy, UserProxy
from .uuid_pk import UUIDUser
Expand All @@ -13,6 +13,7 @@
from .with_last_login_attr import UserWithDisabledLastLoginField

__all__ = (
'CustomModel',
'CustomPermissionsUser', 'CustomUser', 'CustomUserNonUniqueUsername',
'CustomUserWithFK', 'CustomUserWithoutIsActiveField', 'Email',
'ExtensionUser', 'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser',
Expand Down
6 changes: 6 additions & 0 deletions tests/auth_tests/models/minimal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from django.contrib.auth.models import User
from django.db import models


class MinimalUser(models.Model):
REQUIRED_FIELDS = ()
USERNAME_FIELD = 'id'


class CustomModel(models.Model):
# Used by with_perm() tests.
user = models.ForeignKey(User, on_delete=models.CASCADE)
13 changes: 12 additions & 1 deletion tests/auth_tests/test_auth_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,13 +652,24 @@ class ImportedModelBackend(ModelBackend):


class CustomModelBackend(ModelBackend):
pass
def with_perm(self, perm, is_active=True, is_superuser=True, backend=None, obj=None):
if obj is not None and obj.user.username == 'charliebrown':
return User.objects.filter(username='charliebrown')
return User.objects.filter(username__startswith='charlie')


class OtherModelBackend(ModelBackend):
pass


class BareModelBackend(object):
def authenticate(self, username, password):
return None

def get_user(self, user_id):
return None


class ImportedBackendTests(TestCase):
"""
#23925 - The backend path added to the session should be the same
Expand Down
168 changes: 167 additions & 1 deletion tests/auth_tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from django.db.models.signals import post_save
from django.test import SimpleTestCase, TestCase, override_settings

from .models import IntegerUsernameUser
from .models import CustomModel, IntegerUsernameUser
from .models.with_custom_email_field import CustomEmailField


Expand Down Expand Up @@ -261,6 +261,172 @@ def test_check_password_upgrade(self):
hasher.iterations = old_iterations


class UserWithPermTestCase(TestCase):

@classmethod
def setUpTestData(cls):
cls.content_type = ContentType.objects.get_for_model(Group)
cls.permission = Permission.objects.create(
name='test', content_type=cls.content_type, codename='test'
)
cls.user1 = User.objects.create_user('user1', '[email protected]')
cls.user1.user_permissions.add(cls.permission)

cls.group = Group.objects.create(name='test')
cls.group.permissions.add(cls.permission)
cls.group2 = Group.objects.create(name='test2')
cls.group2.permissions.add(cls.permission)
cls.user2 = User.objects.create_user('user2', '[email protected]')
cls.user2.groups.add(cls.group, cls.group2)

cls.user3 = User.objects.create_user('user3', '[email protected]')
cls.superuser = User.objects.create_superuser(
'superuser', '[email protected]', 'superpassword',
)
cls.inactive_user = User.objects.create_user(
'inactive_user', '[email protected]', is_active=False,
)
cls.inactive_user.user_permissions.add(cls.permission)
cls.charlie = User.objects.create_user('charlie', '[email protected]')
cls.charlie_brown = User.objects.create_user('charliebrown', '[email protected]')

def test_invalid_permission_name(self):
msg = "Permission name should be in the form 'app_label.perm_name'."
for perm in ('nodots', 'too.many.dots', '...', ''):
with self.assertRaisesMessage(ValueError, msg):
User.objects.with_perm(perm)

def test_user_permissions(self):
self.assertCountEqual(
User.objects.with_perm('auth.test'),
[self.user1, self.user2, self.superuser],
)

def test_group_permissions(self):
self.assertCountEqual(
User.objects.with_perm('auth.test'),
[self.user1, self.user2, self.superuser],
)

def test_no_permissions(self):
self.assertNotIn(self.user3, User.objects.with_perm('auth.test'))

def test_superuser(self):
self.assertIn(self.superuser, User.objects.with_perm('auth.test'))

def test_exclude_superuser(self):
users = User.objects.with_perm('auth.test', is_superuser=False)
self.assertNotIn(self.superuser, users)
self.assertIn(self.user1, users)

def test_exclude_inactive(self):
self.assertNotIn(self.inactive_user, User.objects.with_perm('auth.test'))

def test_inactive(self):
users = User.objects.with_perm('auth.test', is_active=False)
self.assertIn(self.inactive_user, users)
self.assertNotIn(self.user1, users)

def test_non_duplicate_users(self):
self.assertCountEqual(
User.objects.with_perm('auth.test'),
[self.user1, self.user2, self.superuser],
)

def test_with_permission_instance(self):
self.assertCountEqual(
User.objects.with_perm(self.permission),
[self.user1, self.user2, self.superuser],
)

def test_default_backend_with_permission_instance_and_obj(self):
obj = CustomModel.objects.create(user=self.charlie_brown)
self.assertCountEqual(
User.objects.with_perm(self.permission, obj=obj),
[],
)

@override_settings(AUTHENTICATION_BACKENDS=['auth_tests.test_auth_backends.CustomModelBackend'])
def test_custom_backend_with_permission_instance_and_obj(self):
obj = CustomModel.objects.create(user=self.charlie_brown)
self.assertCountEqual(
User.objects.with_perm(self.permission, obj=obj),
[self.charlie_brown],
)

def test_invalid_perm_type(self):
msg = 'The `perm` argument must be a string or a permission instance.'

class FakePermission:
pass

for perm in (b'auth.test', FakePermission()):
with self.subTest(perm=perm):
with self.assertRaisesMessage(TypeError, msg):
User.objects.with_perm(perm)

@override_settings(AUTHENTICATION_BACKENDS=[
'auth_tests.test_auth_backends.CustomModelBackend',
'django.contrib.auth.backends.ModelBackend',
])
def test_custom_backend(self):
msg = (
'You have multiple authentication backends configured and '
'therefore must provide the `backend` argument.'
)
backend = 'auth_tests.test_auth_backends.CustomModelBackend'
with self.assertRaisesMessage(ValueError, msg):
User.objects.with_perm('auth.test')
self.assertCountEqual(
User.objects.with_perm('auth.test', backend=backend),
[self.charlie, self.charlie_brown],
)
self.assertNotIn(self.user1, User.objects.with_perm('auth.test', backend=backend))

def test_default_backend_with_obj(self):
obj = CustomModel.objects.create(user=self.charlie_brown)
self.assertCountEqual(
User.objects.with_perm('auth.test', obj=obj),
[],
)

@override_settings(AUTHENTICATION_BACKENDS=['auth_tests.test_auth_backends.CustomModelBackend'])
def test_custom_backend_with_obj(self):
obj = CustomModel.objects.create(user=self.charlie_brown)
self.assertCountEqual(
User.objects.with_perm('auth.test', obj=obj),
[self.charlie_brown],
)

def test_invalid_backend_type(self):
msg = 'The `backend` argument must be a string.'
with self.assertRaisesMessage(TypeError, msg):
User.objects.with_perm(
'auth.test',
backend=b'auth_tests.test_auth_backends.CustomModelBackend',
)

def test_nonexistent_backend(self):
with self.assertRaises(ImportError):
User.objects.with_perm(
'auth.test',
backend='invalid.backend.CustomModelBackend',
)

def test_invalid_permission(self):
self.assertCountEqual(
User.objects.with_perm('invalid.perm'),
[self.superuser],
)

@override_settings(AUTHENTICATION_BACKENDS=['auth_tests.test_auth_backends.BareModelBackend'])
def test_backend_without_with_perm(self):
self.assertCountEqual(
User.objects.with_perm('auth.test'),
[],
)


class IsActiveTestCase(TestCase):
"""
Tests the behavior of the guaranteed is_active attribute
Expand Down