Repository: wagtail/wagtail

Issue:
Built-in command `remove_stale_contenttypes` removes `wagtail_admin` permissions
### Issue Summary

When running `remove_stale_contenttypes` it offers to remove `wagtail_admin.admin` content type and all related permissions and all assigned permissions making access to Wagtail admin impossible to anyone but superuser.

### Steps to Reproduce

Run `./manage.py remove_stale_contenttypes`. It will offer:

```
Some content types in your database are stale and can be deleted.
Any objects that depend on these content types will also be deleted.
The content types and dependent objects that would be deleted are:

    - Content type for wagtailadmin.admin
    - 1 auth.Permission object(s)
    - 2 auth.Group_permissions object(s)

This list doesn't include any cascade deletions to data outside of Django's
models (uncommon).

Are you sure you want to delete these content types?
If you're unsure, answer 'no'.
Type 'yes' to continue, or 'no' to cancel:
```

### Technical details

* Python version: 3.5.3
* Django version: 2.0.3
* Wagtail version: 2.0

I suggest using more robust way of creating `wagtail_admin` content type because it is very easy to miss and accidentally remove it along with the permissions if there're lots of stale content types and you just type `yes` to all questions thinking that there're no content types that are actually used by the project.

Maybe there should be a model in Wagtail or wagtailadmin permissions should be tied to some existing model, like `Page`, so that Django doesn't consider wagtailadmin content type unused, stale and safe to remove.



Discussion:
For anyone who's encountered this, you can restore the missing permission type by running the following from the `./manage.py shell` command line:

    from django.contrib.contenttypes.models import ContentType
    from django.contrib.auth.models import Permission
    wagtailadmin_content_type, _ = ContentType.objects.get_or_create(app_label='wagtailadmin', model='admin')
    Permission.objects.get_or_create(content_type=wagtailadmin_content_type, codename='access_admin', name='Can access Wagtail admin')

I faced the same issue, but I never tried the remove_stale_contenttypes command. I have implemented a CustomUser model though. I am trying to find the steps to reproduce. But for sure this issue is deeper than this. Will update once I find the problem
Would be great to see this fixed. Running `remove_stale_contenttype` is useful in many automated deployment scenarios.

As it stands this issue leads to an extremely opaque debugging problem ... I was relieved to find this thread.
This can be fixed with auxiliary unmanaged model just for permissions.

Details: https://stackoverflow.com/questions/13932774/how-can-i-use-django-permissions-without-defining-a-content-type-or-model/37988537#37988537

Resolving patch:
diff --git a/wagtail/admin/migrations/0001_create_admin_access_permissions.py b/wagtail/admin/migrations/0001_create_admin_access_permissions.py
--- a/wagtail/admin/migrations/0001_create_admin_access_permissions.py
+++ b/wagtail/admin/migrations/0001_create_admin_access_permissions.py
@@ -7,8 +7,7 @@ def create_admin_access_permissions(apps, schema_editor):
     Permission = apps.get_model('auth.Permission')
     Group = apps.get_model('auth.Group')
 
-    # Add a fake content type to hang the 'can access Wagtail admin' permission off.
-    # The fact that this doesn't correspond to an actual defined model shouldn't matter, I hope...
+    # Add a content type to hang the 'can access Wagtail admin' permission off
     wagtailadmin_content_type, created = ContentType.objects.get_or_create(
         app_label='wagtailadmin',
         model='admin'
diff --git a/wagtail/admin/migrations/0002_admin.py b/wagtail/admin/migrations/0002_admin.py
new file mode 100644
--- /dev/null
+++ b/wagtail/admin/migrations/0002_admin.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.0.10 on 2020-12-19 15:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('wagtailadmin', '0001_create_admin_access_permissions'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Admin',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+            ],
+            options={
+                'permissions': [('access_admin', 'Can access Wagtail admin')],
+                'managed': False,
+                'default_permissions': [],
+            },
+        ),
+    ]
diff --git a/wagtail/admin/models.py b/wagtail/admin/models.py
--- a/wagtail/admin/models.py
+++ b/wagtail/admin/models.py
@@ -1,5 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Count
+from django.db.models import Count, Model
 from modelcluster.fields import ParentalKey
 from taggit.models import Tag
 
@@ -11,6 +11,18 @@
 from wagtail.core.models import Page
 
 
+# A dummy model that exists purely to attach the access_admin permission type to, so that it
+# doesn't get identified as a stale content type and removed by the remove_stale_contenttypes
+# management command. managed = False ensures that this doesn't create a database table.
+class Admin(Model):
+    class Meta:
+        managed = False
+        default_permissions = []  # don't create the default add / change / delete / view perms
+        permissions = [
+            ('access_admin', "Can access Wagtail admin"),
+        ]
+
+
 def get_object_usage(obj):
     """Returns a queryset of pages that link to a particular object"""
 


Test patch:
diff --git a/wagtail/admin/tests/tests.py b/wagtail/admin/tests/tests.py
--- a/wagtail/admin/tests/tests.py
+++ b/wagtail/admin/tests/tests.py
@@ -7,6 +7,7 @@
 from django.conf import settings
 from django.contrib.auth.models import Group, Permission
 from django.core import mail
+from django.core.management import call_command
 from django.test import TestCase, override_settings
 from django.urls import reverse, reverse_lazy
 from django.utils.translation import gettext_lazy as _
@@ -341,3 +342,11 @@ def test_not_logged_in_redirect(self):
 
         # Check that the user was redirected to the login page and that next was set correctly
         self.assertRedirects(response, reverse('wagtailadmin_login') + '?next=/admin/sdfgdsfgdsfgsdf')
+
+
+class TestRemoveStaleContentTypes(TestCase):
+    def test_remove_stale_content_types_preserves_access_admin_permission(self):
+        call_command('remove_stale_contenttypes', interactive=False)
+        self.assertTrue(
+            Permission.objects.filter(content_type__app_label='wagtailadmin', codename='access_admin').exists()
+        )