Skip to content
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

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

Merged
merged 1 commit into from
Aug 30, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 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 Exists, OuterRef, Q
from django.utils.deprecation import RemovedInDjango31Warning

UserModel = get_user_model()
Expand Down Expand Up @@ -119,6 +120,42 @@ 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, 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)
Expand Down
26 changes: 26 additions & 0 deletions django/contrib/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,32 @@ def create_superuser(self, username, email=None, password=None, **extra_fields):

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'):
felixxm marked this conversation as resolved.
Show resolved Hide resolved
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):
Expand Down
41 changes: 41 additions & 0 deletions docs/ref/contrib/auth.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
========================

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/releases/3.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
7 changes: 4 additions & 3 deletions docs/topics/auth/customizing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions tests/auth_tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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 (
Expand Down Expand Up @@ -261,6 +262,142 @@ def test_check_password_upgrade(self):
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
Expand Down