Fixed #11154, #22270 -- Made proxy model permissions use correct content type.

Co-Authored-By: Simon Charette <charette.s@gmail.com>
Co-Authored-By: Antoine Catton <acatton@fusionbox.com>
This commit is contained in:
Arthur Rio 2019-01-16 16:07:28 +01:00 committed by Tim Graham
parent dbcf2ffa77
commit 181fb60159
12 changed files with 412 additions and 11 deletions

View File

@ -86,6 +86,7 @@ answer newbie questions, and generally made Django that much better:
Artem Gnilov <boobsd@gmail.com> Artem Gnilov <boobsd@gmail.com>
Arthur <avandorp@gmail.com> Arthur <avandorp@gmail.com>
Arthur Koziel <http://arthurkoziel.com> Arthur Koziel <http://arthurkoziel.com>
Arthur Rio <arthur.rio44@gmail.com>
Arvis Bickovskis <viestards.lists@gmail.com> Arvis Bickovskis <viestards.lists@gmail.com>
Aryeh Leib Taurog <http://www.aryehleib.com/> Aryeh Leib Taurog <http://www.aryehleib.com/>
A S Alam <aalam@users.sf.net> A S Alam <aalam@users.sf.net>

View File

@ -60,7 +60,7 @@ def create_permissions(app_config, verbosity=2, interactive=True, using=DEFAULT_
for klass in app_config.get_models(): for klass in app_config.get_models():
# Force looking up the content types in the current database # Force looking up the content types in the current database
# before creating foreign keys to them. # before creating foreign keys to them.
ctype = ContentType.objects.db_manager(using).get_for_model(klass) ctype = ContentType.objects.db_manager(using).get_for_model(klass, for_concrete_model=False)
ctypes.add(ctype) ctypes.add(ctype)
for perm in _get_all_permissions(klass._meta): for perm in _get_all_permissions(klass._meta):

View File

@ -0,0 +1,48 @@
from django.db import migrations
from django.db.models import Q
def update_proxy_model_permissions(apps, schema_editor, reverse=False):
"""
Update the content_type of proxy model permissions to use the ContentType
of the proxy model.
"""
Permission = apps.get_model('auth', 'Permission')
ContentType = apps.get_model('contenttypes', 'ContentType')
for Model in apps.get_models():
opts = Model._meta
if not opts.proxy:
continue
proxy_default_permissions_codenames = [
'%s_%s' % (action, opts.model_name)
for action in opts.default_permissions
]
permissions_query = Q(codename__in=proxy_default_permissions_codenames)
for codename, name in opts.permissions:
permissions_query = permissions_query | Q(codename=codename, name=name)
concrete_content_type = ContentType.objects.get_for_model(Model, for_concrete_model=True)
proxy_content_type = ContentType.objects.get_for_model(Model, for_concrete_model=False)
old_content_type = proxy_content_type if reverse else concrete_content_type
new_content_type = concrete_content_type if reverse else proxy_content_type
Permission.objects.filter(
permissions_query,
content_type=old_content_type,
).update(content_type=new_content_type)
def revert_proxy_model_permissions(apps, schema_editor):
"""
Update the content_type of proxy model permissions to use the ContentType
of the concrete model.
"""
update_proxy_model_permissions(apps, schema_editor, reverse=True)
class Migration(migrations.Migration):
dependencies = [
('auth', '0010_alter_group_name_max_length'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.RunPython(update_proxy_model_permissions, revert_proxy_model_permissions),
]

View File

@ -439,6 +439,28 @@ Use this instead::
alias = property(operator.attrgetter('base')) alias = property(operator.attrgetter('base'))
Permissions for proxy models
----------------------------
:ref:`Permissions for proxy models <proxy-models-permissions-topic>` are now
created using the content type of the proxy model rather than the content type
of the concrete model. A migration will update existing permissions when you
run :djadmin:`migrate`.
In the admin, the change is transparent for proxy models having the same
``app_label`` as their concrete model. However, in older versions, users with
permissions for a proxy model with a *different* ``app_label`` than its
concrete model couldn't access the model in the admin. That's now fixed, but
you might want to audit the permissions assignments for such proxy models
(``[add|view|change|delete]_myproxy``) prior to upgrading to ensure the new
access is appropriate.
Finally, proxy model permission strings must be updated to use their own
``app_label``. For example, for ``app.MyProxyModel`` inheriting from
``other_app.ConcreteModel``, update
``user.has_perm('other_app.add_myproxymodel')`` to
``user.has_perm('app.add_myproxymodel')``.
Miscellaneous Miscellaneous
------------- -------------

View File

@ -262,6 +262,20 @@ The permission can then be assigned to a
attribute or to a :class:`~django.contrib.auth.models.Group` via its attribute or to a :class:`~django.contrib.auth.models.Group` via its
``permissions`` attribute. ``permissions`` attribute.
.. admonition:: Proxy models need their own content type
If you want to create :ref:`permissions for a proxy model
<proxy-models-permissions-topic>`, pass ``for_concrete_model=False`` to
:meth:`.ContentTypeManager.get_for_model` to get the appropriate
``ContentType``::
content_type = ContentType.objects.get_for_model(BlogPostProxy, for_concrete_model=False)
.. versionchanged:: 2.2
In older versions, proxy models use the content type of the concrete
model.
Permission caching Permission caching
------------------ ------------------
@ -303,6 +317,44 @@ the user from the database. For example::
... ...
.. _proxy-models-permissions-topic:
Proxy models
------------
Proxy models work exactly the same way as concrete models. Permissions are
created using the own content type of the proxy model. Proxy models don't
inherit the permissions of the concrete model they subclass::
class Person(models.Model):
class Meta:
permissions = (('can_eat_pizzas', 'Can eat pizzas'),)
class Student(Person):
class Meta:
proxy = True
permissions = (('can_deliver_pizzas', 'Can deliver pizzas'),)
>>> # Fetch the content type for the proxy model.
>>> content_type = ContentType.objects.get_for_model(Student, for_concrete_model=False)
>>> student_permissions = Permission.objects.filter(content_type=content_type)
>>> [p.codename for p in student_permissions]
['add_student', 'change_student', 'delete_student', 'view_student',
'can_deliver_pizzas']
>>> for permission in student_permissions:
... user.user_permissions.add(permission)
>>> user.has_perm('app.add_person')
False
>>> user.has_perm('app.can_eat_pizzas')
False
>>> user.has_perms(('app.add_student', 'app.can_deliver_pizzas'))
True
.. versionchanged:: 2.2
In older versions, permissions for proxy models use the content type of
the concrete model rather than content type of the proxy model.
.. _auth-web-requests: .. _auth-web-requests:
Authentication in Web requests Authentication in Web requests

View File

@ -43,7 +43,8 @@ from .models import (
Restaurant, RowLevelChangePermissionModel, Section, ShortMessage, Simple, Restaurant, RowLevelChangePermissionModel, Section, ShortMessage, Simple,
Sketch, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram, Thing, Sketch, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram, Thing,
Topping, UnchangeableObject, UndeletableObject, UnorderedObject, Topping, UnchangeableObject, UndeletableObject, UnorderedObject,
UserMessenger, Villain, Vodcast, Whatsit, Widget, Worker, WorkHour, UserMessenger, UserProxy, Villain, Vodcast, Whatsit, Widget, Worker,
WorkHour,
) )
@ -1075,6 +1076,7 @@ site.register(Ingredient)
site.register(NotReferenced) site.register(NotReferenced)
site.register(ExplicitlyProvidedPK, GetFormsetsArgumentCheckingAdmin) site.register(ExplicitlyProvidedPK, GetFormsetsArgumentCheckingAdmin)
site.register(ImplicitlyGeneratedPK, GetFormsetsArgumentCheckingAdmin) site.register(ImplicitlyGeneratedPK, GetFormsetsArgumentCheckingAdmin)
site.register(UserProxy)
# Register core models we need in our tests # Register core models we need in our tests
site.register(User, UserAdmin) site.register(User, UserAdmin)

View File

@ -976,3 +976,9 @@ class Author(models.Model):
class Authorship(models.Model): class Authorship(models.Model):
book = models.ForeignKey(Book, models.CASCADE) book = models.ForeignKey(Book, models.CASCADE)
author = models.ForeignKey(Author, models.CASCADE) author = models.ForeignKey(Author, models.CASCADE)
class UserProxy(User):
"""Proxy a model with a different app_label."""
class Meta:
proxy = True

View File

@ -52,7 +52,8 @@ from .models import (
Report, Restaurant, RowLevelChangePermissionModel, SecretHideout, Section, Report, Restaurant, RowLevelChangePermissionModel, SecretHideout, Section,
ShortMessage, Simple, State, Story, SuperSecretHideout, SuperVillain, ShortMessage, Simple, State, Story, SuperSecretHideout, SuperVillain,
Telegram, TitleTranslation, Topping, UnchangeableObject, UndeletableObject, Telegram, TitleTranslation, Topping, UnchangeableObject, UndeletableObject,
UnorderedObject, Villain, Vodcast, Whatsit, Widget, Worker, WorkHour, UnorderedObject, UserProxy, Villain, Vodcast, Whatsit, Widget, Worker,
WorkHour,
) )
ERROR_MESSAGE = "Please enter the correct username and password \ ERROR_MESSAGE = "Please enter the correct username and password \
@ -1362,10 +1363,10 @@ class CustomModelAdminTest(AdminViewBasicTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def get_perm(Model, perm): def get_perm(Model, codename):
"""Return the permission object, for the Model""" """Return the permission object, for the Model"""
ct = ContentType.objects.get_for_model(Model) ct = ContentType.objects.get_for_model(Model, for_concrete_model=False)
return Permission.objects.get(content_type=ct, codename=perm) return Permission.objects.get(content_type=ct, codename=codename)
@override_settings( @override_settings(
@ -2384,6 +2385,81 @@ class AdminViewPermissionsTest(TestCase):
) )
@override_settings(
ROOT_URLCONF='admin_views.urls',
TEMPLATES=[{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
}],
)
class AdminViewProxyModelPermissionsTests(TestCase):
"""Tests for proxy models permissions in the admin."""
@classmethod
def setUpTestData(cls):
cls.viewuser = User.objects.create_user(username='viewuser', password='secret', is_staff=True)
cls.adduser = User.objects.create_user(username='adduser', password='secret', is_staff=True)
cls.changeuser = User.objects.create_user(username='changeuser', password='secret', is_staff=True)
cls.deleteuser = User.objects.create_user(username='deleteuser', password='secret', is_staff=True)
# Setup permissions.
opts = UserProxy._meta
cls.viewuser.user_permissions.add(get_perm(UserProxy, get_permission_codename('view', opts)))
cls.adduser.user_permissions.add(get_perm(UserProxy, get_permission_codename('add', opts)))
cls.changeuser.user_permissions.add(get_perm(UserProxy, get_permission_codename('change', opts)))
cls.deleteuser.user_permissions.add(get_perm(UserProxy, get_permission_codename('delete', opts)))
# UserProxy instances.
cls.user_proxy = UserProxy.objects.create(username='user_proxy', password='secret')
def test_add(self):
self.client.force_login(self.adduser)
url = reverse('admin:admin_views_userproxy_add')
data = {
'username': 'can_add',
'password': 'secret',
'date_joined_0': '2019-01-15',
'date_joined_1': '16:59:10',
}
response = self.client.post(url, data, follow=True)
self.assertEqual(response.status_code, 200)
self.assertTrue(UserProxy.objects.filter(username='can_add').exists())
def test_view(self):
self.client.force_login(self.viewuser)
response = self.client.get(reverse('admin:admin_views_userproxy_changelist'))
self.assertContains(response, '<h1>Select user proxy to view</h1>')
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('admin:admin_views_userproxy_change', args=(self.user_proxy.pk,)))
self.assertContains(response, '<h1>View user proxy</h1>')
self.assertContains(response, '<div class="readonly">user_proxy</div>')
def test_change(self):
self.client.force_login(self.changeuser)
data = {
'password': self.user_proxy.password,
'username': self.user_proxy.username,
'date_joined_0': self.user_proxy.date_joined.strftime('%Y-%m-%d'),
'date_joined_1': self.user_proxy.date_joined.strftime('%H:%M:%S'),
'first_name': 'first_name',
}
url = reverse('admin:admin_views_userproxy_change', args=(self.user_proxy.pk,))
response = self.client.post(url, data)
self.assertRedirects(response, reverse('admin:admin_views_userproxy_changelist'))
self.assertEqual(UserProxy.objects.get(pk=self.user_proxy.pk).first_name, 'first_name')
def test_delete(self):
self.client.force_login(self.deleteuser)
url = reverse('admin:admin_views_userproxy_delete', args=(self.user_proxy.pk,))
response = self.client.post(url, {'post': 'yes'}, follow=True)
self.assertEqual(response.status_code, 200)
self.assertFalse(UserProxy.objects.filter(pk=self.user_proxy.pk).exists())
@override_settings(ROOT_URLCONF='admin_views.urls') @override_settings(ROOT_URLCONF='admin_views.urls')
class AdminViewsNoUrlTest(TestCase): class AdminViewsNoUrlTest(TestCase):
"""Regression test for #17333""" """Regression test for #17333"""

View File

@ -6,14 +6,16 @@ from .invalid_models import CustomUserNonUniqueUsername
from .is_active import IsActiveTestUser1 from .is_active import IsActiveTestUser1
from .minimal import MinimalUser from .minimal import MinimalUser
from .no_password import NoPasswordUser from .no_password import NoPasswordUser
from .proxy import Proxy, UserProxy
from .uuid_pk import UUIDUser from .uuid_pk import UUIDUser
from .with_foreign_key import CustomUserWithFK, Email from .with_foreign_key import CustomUserWithFK, Email
from .with_integer_username import IntegerUsernameUser from .with_integer_username import IntegerUsernameUser
from .with_last_login_attr import UserWithDisabledLastLoginField from .with_last_login_attr import UserWithDisabledLastLoginField
__all__ = ( __all__ = (
'CustomUser', 'CustomUserWithoutIsActiveField', 'CustomPermissionsUser', 'CustomPermissionsUser', 'CustomUser', 'CustomUserNonUniqueUsername',
'CustomUserWithFK', 'Email', 'ExtensionUser', 'IsActiveTestUser1', 'CustomUserWithFK', 'CustomUserWithoutIsActiveField', 'Email',
'MinimalUser', 'NoPasswordUser', 'UUIDUser', 'CustomUserNonUniqueUsername', 'ExtensionUser', 'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser',
'IntegerUsernameUser', 'UserWithDisabledLastLoginField', 'NoPasswordUser', 'Proxy', 'UUIDUser', 'UserProxy',
'UserWithDisabledLastLoginField',
) )

View File

@ -0,0 +1,22 @@
from django.contrib.auth.models import User
from django.db import models
class Concrete(models.Model):
pass
class Proxy(Concrete):
class Meta:
proxy = True
permissions = (
('display_proxys', 'May display proxys information'),
)
class UserProxy(User):
class Meta:
proxy = True
permissions = (
('use_different_app_label', 'May use a different app label'),
)

View File

@ -6,7 +6,7 @@ from io import StringIO
from unittest import mock from unittest import mock
from django.apps import apps from django.apps import apps
from django.contrib.auth import management from django.contrib.auth import get_permission_codename, management
from django.contrib.auth.management import ( from django.contrib.auth.management import (
create_permissions, get_default_username, create_permissions, get_default_username,
) )
@ -23,6 +23,7 @@ from django.utils.translation import gettext_lazy as _
from .models import ( from .models import (
CustomUser, CustomUserNonUniqueUsername, CustomUserWithFK, Email, CustomUser, CustomUserNonUniqueUsername, CustomUserWithFK, Email,
UserProxy,
) )
MOCK_INPUT_KEY_TO_PROMPTS = { MOCK_INPUT_KEY_TO_PROMPTS = {
@ -985,3 +986,18 @@ class CreatePermissionsTests(TestCase):
ContentType.objects.filter(app_label='auth', model='group').delete() ContentType.objects.filter(app_label='auth', model='group').delete()
# This fails with a foreign key constraint without the fix. # This fails with a foreign key constraint without the fix.
create_permissions(apps.get_app_config('auth'), interactive=False, verbosity=0) create_permissions(apps.get_app_config('auth'), interactive=False, verbosity=0)
def test_permission_with_proxy_content_type_created(self):
"""
A proxy model's permissions use its own content type rather than the
content type of the concrete model.
"""
opts = UserProxy._meta
codename = get_permission_codename('add', opts)
self.assertTrue(
Permission.objects.filter(
content_type__model=opts.model_name,
content_type__app_label=opts.app_label,
codename=codename,
).exists()
)

View File

@ -0,0 +1,154 @@
from importlib import import_module
from django.apps import apps
from django.contrib.auth.models import Permission, User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from .models import Proxy, UserProxy
update_proxy_permissions = import_module('django.contrib.auth.migrations.0011_update_proxy_permissions')
class ProxyModelWithDifferentAppLabelTests(TestCase):
available_apps = [
'auth_tests',
'django.contrib.auth',
'django.contrib.contenttypes',
]
def setUp(self):
"""
Create proxy permissions with content_type to the concrete model
rather than the proxy model (as they were before Django 2.2 and
migration 11).
"""
Permission.objects.all().delete()
self.concrete_content_type = ContentType.objects.get_for_model(UserProxy)
self.default_permission = Permission.objects.create(
content_type=self.concrete_content_type,
codename='add_userproxy',
name='Can add userproxy',
)
self.custom_permission = Permission.objects.create(
content_type=self.concrete_content_type,
codename='use_different_app_label',
name='May use a different app label',
)
def test_proxy_model_permissions_contenttype(self):
proxy_model_content_type = ContentType.objects.get_for_model(UserProxy, for_concrete_model=False)
self.assertEqual(self.default_permission.content_type, self.concrete_content_type)
self.assertEqual(self.custom_permission.content_type, self.concrete_content_type)
update_proxy_permissions.update_proxy_model_permissions(apps, None)
self.default_permission.refresh_from_db()
self.assertEqual(self.default_permission.content_type, proxy_model_content_type)
self.custom_permission.refresh_from_db()
self.assertEqual(self.custom_permission.content_type, proxy_model_content_type)
def test_user_has_now_proxy_model_permissions(self):
user = User.objects.create()
user.user_permissions.add(self.default_permission)
user.user_permissions.add(self.custom_permission)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth.' + permission.codename))
self.assertFalse(user.has_perm('auth_tests.' + permission.codename))
update_proxy_permissions.update_proxy_model_permissions(apps, None)
# Reload user to purge the _perm_cache.
user = User._default_manager.get(pk=user.pk)
for permission in [self.default_permission, self.custom_permission]:
self.assertFalse(user.has_perm('auth.' + permission.codename))
self.assertTrue(user.has_perm('auth_tests.' + permission.codename))
def test_migrate_backwards(self):
update_proxy_permissions.update_proxy_model_permissions(apps, None)
update_proxy_permissions.revert_proxy_model_permissions(apps, None)
self.default_permission.refresh_from_db()
self.assertEqual(self.default_permission.content_type, self.concrete_content_type)
self.custom_permission.refresh_from_db()
self.assertEqual(self.custom_permission.content_type, self.concrete_content_type)
def test_user_keeps_same_permissions_after_migrating_backward(self):
user = User.objects.create()
user.user_permissions.add(self.default_permission)
user.user_permissions.add(self.custom_permission)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth.' + permission.codename))
self.assertFalse(user.has_perm('auth_tests.' + permission.codename))
update_proxy_permissions.update_proxy_model_permissions(apps, None)
update_proxy_permissions.revert_proxy_model_permissions(apps, None)
# Reload user to purge the _perm_cache.
user = User._default_manager.get(pk=user.pk)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth.' + permission.codename))
self.assertFalse(user.has_perm('auth_tests.' + permission.codename))
class ProxyModelWithSameAppLabelTests(TestCase):
available_apps = [
'auth_tests',
'django.contrib.auth',
'django.contrib.contenttypes',
]
def setUp(self):
"""
Create proxy permissions with content_type to the concrete model
rather than the proxy model (as they were before Django 2.2 and
migration 11).
"""
Permission.objects.all().delete()
self.concrete_content_type = ContentType.objects.get_for_model(Proxy)
self.default_permission = Permission.objects.create(
content_type=self.concrete_content_type,
codename='add_proxy',
name='Can add proxy',
)
self.custom_permission = Permission.objects.create(
content_type=self.concrete_content_type,
codename='display_proxys',
name='May display proxys information',
)
def test_proxy_model_permissions_contenttype(self):
proxy_model_content_type = ContentType.objects.get_for_model(Proxy, for_concrete_model=False)
self.assertEqual(self.default_permission.content_type, self.concrete_content_type)
self.assertEqual(self.custom_permission.content_type, self.concrete_content_type)
update_proxy_permissions.update_proxy_model_permissions(apps, None)
self.default_permission.refresh_from_db()
self.custom_permission.refresh_from_db()
self.assertEqual(self.default_permission.content_type, proxy_model_content_type)
self.assertEqual(self.custom_permission.content_type, proxy_model_content_type)
def test_user_still_has_proxy_model_permissions(self):
user = User.objects.create()
user.user_permissions.add(self.default_permission)
user.user_permissions.add(self.custom_permission)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth_tests.' + permission.codename))
update_proxy_permissions.update_proxy_model_permissions(apps, None)
# Reload user to purge the _perm_cache.
user = User._default_manager.get(pk=user.pk)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth_tests.' + permission.codename))
def test_migrate_backwards(self):
update_proxy_permissions.update_proxy_model_permissions(apps, None)
update_proxy_permissions.revert_proxy_model_permissions(apps, None)
self.default_permission.refresh_from_db()
self.assertEqual(self.default_permission.content_type, self.concrete_content_type)
self.custom_permission.refresh_from_db()
self.assertEqual(self.custom_permission.content_type, self.concrete_content_type)
def test_user_keeps_same_permissions_after_migrating_backward(self):
user = User.objects.create()
user.user_permissions.add(self.default_permission)
user.user_permissions.add(self.custom_permission)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth_tests.' + permission.codename))
update_proxy_permissions.update_proxy_model_permissions(apps, None)
update_proxy_permissions.revert_proxy_model_permissions(apps, None)
# Reload user to purge the _perm_cache.
user = User._default_manager.get(pk=user.pk)
for permission in [self.default_permission, self.custom_permission]:
self.assertTrue(user.has_perm('auth_tests.' + permission.codename))