From 00124bfe04d6235e0d30e8b7a5eaf878e37c64ac Mon Sep 17 00:00:00 2001 From: David Paul Graham <43794491+dpgraham4401@users.noreply.github.com> Date: Mon, 22 Jul 2024 17:19:01 -0400 Subject: [PATCH] Guardian setup (#750) * Bump django from 5.0.6 to 5.0.7 in /server Bumps [django](https://github.com/django/django) from 5.0.6 to 5.0.7. - [Commits](https://github.com/django/django/compare/5.0.6...5.0.7) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] * add Django Guardian to project * configure django guardian and add Site and Org specific object level permission models * remove our implementations of the Role and Permission model * adjust site permission/role verbose name and admin representation --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- server/apps/core/admin.py | 6 +- .../0004_grouppermission_userpermission.py | 47 ++++++++++++++++ ..._permissions_delete_permission_and_more.py | 23 ++++++++ server/apps/core/models.py | 55 ++++++------------- server/apps/core/tests/conftest.py | 22 -------- server/apps/core/tests/test_models.py | 42 -------------- server/apps/org/admin.py | 23 +++++++- ...bjectpermission_orguserobjectpermission.py | 43 +++++++++++++++ server/apps/org/models.py | 13 +++++ server/apps/site/admin.py | 23 +++++++- ...ions_sitegroupobjectpermission_and_more.py | 47 ++++++++++++++++ ...egroupobjectpermission_options_and_more.py | 21 +++++++ server/apps/site/models.py | 25 ++++++++- server/haztrak/settings/base.py | 7 +++ server/requirements.txt | 3 +- 15 files changed, 290 insertions(+), 110 deletions(-) create mode 100644 server/apps/core/migrations/0004_grouppermission_userpermission.py create mode 100644 server/apps/core/migrations/0005_remove_role_permissions_delete_permission_and_more.py delete mode 100644 server/apps/core/tests/test_models.py create mode 100644 server/apps/org/migrations/0003_orggroupobjectpermission_orguserobjectpermission.py create mode 100644 server/apps/site/migrations/0002_alter_siteaccess_options_sitegroupobjectpermission_and_more.py create mode 100644 server/apps/site/migrations/0003_alter_sitegroupobjectpermission_options_and_more.py diff --git a/server/apps/core/admin.py b/server/apps/core/admin.py index 50288e80..2f4a1b7a 100644 --- a/server/apps/core/admin.py +++ b/server/apps/core/admin.py @@ -5,7 +5,7 @@ from apps.profile.models import Profile, RcrainfoProfile, RcrainfoSiteAccess -from .models import Permission, Role, TrakUser +from .models import GroupPermission, TrakUser, UserPermission class HiddenListView(admin.ModelAdmin): @@ -73,5 +73,5 @@ def api_user(self, profile: RcrainfoProfile) -> bool: admin.site.register(Profile) admin.site.unregister(DRFToken) -admin.site.register(Permission) -admin.site.register(Role) +admin.site.register(UserPermission) +admin.site.register(GroupPermission) diff --git a/server/apps/core/migrations/0004_grouppermission_userpermission.py b/server/apps/core/migrations/0004_grouppermission_userpermission.py new file mode 100644 index 00000000..516e25a8 --- /dev/null +++ b/server/apps/core/migrations/0004_grouppermission_userpermission.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.6 on 2024-07-22 19:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0003_role'), + ] + + operations = [ + migrations.CreateModel( + name='GroupPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_pk', models.CharField(max_length=255, verbose_name='object ID')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.group')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ], + options={ + 'abstract': False, + 'indexes': [models.Index(fields=['content_type', 'object_pk'], name='core_groupp_content_9384ee_idx')], + 'unique_together': {('group', 'permission', 'object_pk')}, + }, + ), + migrations.CreateModel( + name='UserPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_pk', models.CharField(max_length=255, verbose_name='object ID')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'indexes': [models.Index(fields=['content_type', 'object_pk'], name='core_userpe_content_92a909_idx')], + 'unique_together': {('user', 'permission', 'object_pk')}, + }, + ), + ] diff --git a/server/apps/core/migrations/0005_remove_role_permissions_delete_permission_and_more.py b/server/apps/core/migrations/0005_remove_role_permissions_delete_permission_and_more.py new file mode 100644 index 00000000..c8faeec0 --- /dev/null +++ b/server/apps/core/migrations/0005_remove_role_permissions_delete_permission_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-07-22 20:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_grouppermission_userpermission'), + ] + + operations = [ + migrations.RemoveField( + model_name='role', + name='permissions', + ), + migrations.DeleteModel( + name='Permission', + ), + migrations.DeleteModel( + name='Role', + ), + ] diff --git a/server/apps/core/models.py b/server/apps/core/models.py index ba370b19..d7539bc1 100644 --- a/server/apps/core/models.py +++ b/server/apps/core/models.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import AbstractUser, Group from django.contrib.auth.models import Permission as DjangoPermission from django.db import models -from django.utils.translation import gettext_lazy as _ +from guardian.models import GroupObjectPermissionAbstract, UserObjectPermissionAbstract class TrakUser(AbstractUser): @@ -21,44 +21,23 @@ class Meta: ) -class Permission(DjangoPermission): - """Haztrak proxy permission model used for our custom object level permissions.""" - - class Meta: - proxy = True - verbose_name = "Permission" - verbose_name_plural = "Permissions" - ordering = ["name"] - - @property - def app_label(self): - return self.content_type.app_label - - @property - def model_name(self): - return self.content_type.model - - def __str__(self): - return f"{self.content_type.name} | {self.name}" +class UserPermission(UserObjectPermissionAbstract): + """ + User object permission model for Haztrak. + access via guardian.utils.get_user_obj_perms_model() + We define this class if we need to customize User object level permissions later. + """ + class Meta(UserObjectPermissionAbstract.Meta): + abstract = False -class Role(models.Model): - """A job/function within the system that can assigned to users to grant them permissions.""" - class Meta: - verbose_name = _("Role") - verbose_name_plural = _("Roles") - ordering = ["name"] - - name = models.CharField( - _("name"), - max_length=150, - unique=True, - ) - permissions = models.ManyToManyField( - Permission, - verbose_name=_("permissions"), - ) +class GroupPermission(GroupObjectPermissionAbstract): + """ + Group object permission model for Haztrak. + access via guardian.utils get_group_obj_perms_model() + We define this class if we need to customize Group object level permissions later. + """ - def __str__(self): - return f"{self.name}" + class Meta(GroupObjectPermissionAbstract.Meta): + abstract = False diff --git a/server/apps/core/tests/conftest.py b/server/apps/core/tests/conftest.py index babf8a27..ab40962d 100644 --- a/server/apps/core/tests/conftest.py +++ b/server/apps/core/tests/conftest.py @@ -2,10 +2,8 @@ from typing import Dict, List, Optional import pytest -from django.contrib.contenttypes.models import ContentType from faker import Faker -from apps.core.models import Permission from apps.rcrasite.models import RcraSiteType @@ -42,23 +40,3 @@ def create_quicker_sign( } return create_quicker_sign - - -@pytest.fixture -def permission_factory(faker: Faker): - """ - Factory for creating dynamic permission data - """ - - def create_permission( - name: str = faker.word(), - content_type_id: int = faker.random_int(min=1), - ) -> Permission: - content_type = ContentType.objects.create(app_label=faker.word(), model=faker.word()) - return Permission.objects.create( - name=name, - content_type=content_type, - content_type_id=content_type_id, - ) - - return create_permission diff --git a/server/apps/core/tests/test_models.py b/server/apps/core/tests/test_models.py deleted file mode 100644 index c00a0baf..00000000 --- a/server/apps/core/tests/test_models.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest -from django.contrib.contenttypes.models import ContentType - -from apps.core.models import Permission, Role - - -class TestPermissionModel: - mock_app_label = "test_app" - mock_model = "test_model" - - @pytest.fixture - def mock_content_type(self): - return ContentType.objects.create(app_label=self.mock_app_label, model=self.mock_model) - - @pytest.mark.django_db - def test_saves_new_permissions(self, mock_content_type): - permission_name = "test_permission" - permission = Permission.objects.create( - content_type=mock_content_type, content_type_id=1, name=permission_name - ) - - saved_permission = Permission.objects.get(name=permission_name) - - assert saved_permission.name == permission_name - assert saved_permission.id == permission.id - - -class TestRoleModel: - @pytest.mark.django_db - def test_role_with_multiple_permissions(self, permission_factory): - permission1 = permission_factory(content_type_id=1) - permission2 = permission_factory(content_type_id=2) - role = Role.objects.create(name="test_role") - role.permissions.add(permission1, permission2) - assert role.permissions.count() == 2 - assert permission1 in role.permissions.all() - assert permission2 in role.permissions.all() - - @pytest.mark.django_db - def test_role_str_representation(self): - role = Role.objects.create(name="test_role") - assert str(role) == "test_role" diff --git a/server/apps/org/admin.py b/server/apps/org/admin.py index 90db35fe..61af84df 100644 --- a/server/apps/org/admin.py +++ b/server/apps/org/admin.py @@ -1,6 +1,9 @@ from django.contrib import admin +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from guardian.admin import GuardedModelAdmin -from apps.org.models import Org, OrgAccess +from apps.org.models import Org, OrgAccess, OrgGroupObjectPermission, OrgUserObjectPermission from apps.site.models import Site admin.site.register(OrgAccess) @@ -19,3 +22,21 @@ def rcrainfo_integrated(self, obj): def number_of_sites(self, org: Org): return Site.objects.filter(org=org).count() + + +@admin.register(OrgUserObjectPermission) +class OrgUserObjectPermissionAdmin(GuardedModelAdmin): + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "permission": + content_type = ContentType.objects.get_for_model(Site) + kwargs["queryset"] = Permission.objects.filter(content_type=content_type) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +@admin.register(OrgGroupObjectPermission) +class OrgGroupObjectPermissionAdmin(GuardedModelAdmin): + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "permission": + content_type = ContentType.objects.get_for_model(Site) + kwargs["queryset"] = Permission.objects.filter(content_type=content_type) + return super().formfield_for_foreignkey(db_field, request, **kwargs) diff --git a/server/apps/org/migrations/0003_orggroupobjectpermission_orguserobjectpermission.py b/server/apps/org/migrations/0003_orggroupobjectpermission_orguserobjectpermission.py new file mode 100644 index 00000000..9cac1c33 --- /dev/null +++ b/server/apps/org/migrations/0003_orggroupobjectpermission_orguserobjectpermission.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.6 on 2024-07-22 19:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('org', '0002_rename_trakorg_org_rename_trakorgaccess_orgaccess'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OrgGroupObjectPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='org.org')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.group')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ], + options={ + 'abstract': False, + 'unique_together': {('group', 'permission', 'content_object')}, + }, + ), + migrations.CreateModel( + name='OrgUserObjectPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='org.org')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'unique_together': {('user', 'permission', 'content_object')}, + }, + ), + ] diff --git a/server/apps/org/models.py b/server/apps/org/models.py index 0901c1bc..fbaa958f 100644 --- a/server/apps/org/models.py +++ b/server/apps/org/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db import models +from guardian.models.models import GroupObjectPermissionBase, UserObjectPermissionBase from apps.profile.models import RcrainfoProfile @@ -86,3 +87,15 @@ class Meta: def __str__(self): return f"{self.user} - {self.org}" + + +class OrgUserObjectPermission(UserObjectPermissionBase): + """Org object level permission.""" + + content_object = models.ForeignKey(Org, on_delete=models.CASCADE) + + +class OrgGroupObjectPermission(GroupObjectPermissionBase): + """Org object level Group.""" + + content_object = models.ForeignKey(Org, on_delete=models.CASCADE) diff --git a/server/apps/site/admin.py b/server/apps/site/admin.py index 5fd18a9a..ad87c304 100644 --- a/server/apps/site/admin.py +++ b/server/apps/site/admin.py @@ -1,6 +1,9 @@ from django.contrib import admin +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from guardian.admin import GuardedModelAdmin -from apps.site.models import Site +from apps.site.models import Site, SiteGroupObjectPermission, SiteUserObjectPermission @admin.register(Site) @@ -8,3 +11,21 @@ class HaztrakSiteAdmin(admin.ModelAdmin): list_display = ["__str__", "last_rcrainfo_manifest_sync"] readonly_fields = ["rcra_site"] search_fields = ["rcra_site__epa_id"] + + +@admin.register(SiteUserObjectPermission) +class SiteUserObjectPermissionAdmin(GuardedModelAdmin): + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "permission": + content_type = ContentType.objects.get_for_model(Site) + kwargs["queryset"] = Permission.objects.filter(content_type=content_type) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +@admin.register(SiteGroupObjectPermission) +class SiteGroupObjectPermissionAdmin(GuardedModelAdmin): + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "permission": + content_type = ContentType.objects.get_for_model(Site) + kwargs["queryset"] = Permission.objects.filter(content_type=content_type) + return super().formfield_for_foreignkey(db_field, request, **kwargs) diff --git a/server/apps/site/migrations/0002_alter_siteaccess_options_sitegroupobjectpermission_and_more.py b/server/apps/site/migrations/0002_alter_siteaccess_options_sitegroupobjectpermission_and_more.py new file mode 100644 index 00000000..000941b3 --- /dev/null +++ b/server/apps/site/migrations/0002_alter_siteaccess_options_sitegroupobjectpermission_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.6 on 2024-07-22 19:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('site', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='siteaccess', + options={'ordering': ['user'], 'verbose_name': 'Site Permission', 'verbose_name_plural': 'Site Permissions'}, + ), + migrations.CreateModel( + name='SiteGroupObjectPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='site.site')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.group')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ], + options={ + 'abstract': False, + 'unique_together': {('group', 'permission', 'content_object')}, + }, + ), + migrations.CreateModel( + name='SiteUserObjectPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='site.site')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'unique_together': {('user', 'permission', 'content_object')}, + }, + ), + ] diff --git a/server/apps/site/migrations/0003_alter_sitegroupobjectpermission_options_and_more.py b/server/apps/site/migrations/0003_alter_sitegroupobjectpermission_options_and_more.py new file mode 100644 index 00000000..fd0d940b --- /dev/null +++ b/server/apps/site/migrations/0003_alter_sitegroupobjectpermission_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.7 on 2024-07-22 20:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('site', '0002_alter_siteaccess_options_sitegroupobjectpermission_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='sitegroupobjectpermission', + options={'verbose_name': 'Site Role', 'verbose_name_plural': 'Site Roles'}, + ), + migrations.AlterModelOptions( + name='siteuserobjectpermission', + options={'verbose_name': 'Site Permission', 'verbose_name_plural': 'Site Permissions'}, + ), + ] diff --git a/server/apps/site/models.py b/server/apps/site/models.py index 7f6c2189..cd6e3f93 100644 --- a/server/apps/site/models.py +++ b/server/apps/site/models.py @@ -2,6 +2,7 @@ from django.core.validators import MinLengthValidator from django.db import models from django.db.models import QuerySet +from guardian.models.models import GroupObjectPermissionBase, UserObjectPermissionBase class SiteManager(QuerySet): @@ -94,8 +95,8 @@ class SiteAccess(models.Model): """The Role Based access a user has to a site""" class Meta: - verbose_name = "New Site Permission" - verbose_name_plural = "New Site Permissions" + verbose_name = "Site Permission" + verbose_name_plural = "Site Permissions" ordering = ["user"] user = models.ForeignKey( @@ -119,3 +120,23 @@ class Meta: def __str__(self): return f"{self.user.username}" + + +class SiteUserObjectPermission(UserObjectPermissionBase): + """Site object level permission.""" + + class Meta(UserObjectPermissionBase.Meta): + verbose_name = "Site Permission" + verbose_name_plural = "Site Permissions" + + content_object = models.ForeignKey(Site, on_delete=models.CASCADE) + + +class SiteGroupObjectPermission(GroupObjectPermissionBase): + """Site object level Group.""" + + class Meta(GroupObjectPermissionBase.Meta): + verbose_name = "Site Role" + verbose_name_plural = "Site Roles" + + content_object = models.ForeignKey(Site, on_delete=models.CASCADE) diff --git a/server/haztrak/settings/base.py b/server/haztrak/settings/base.py index b1fd6c36..340454b3 100644 --- a/server/haztrak/settings/base.py +++ b/server/haztrak/settings/base.py @@ -35,6 +35,7 @@ "django.contrib.staticfiles", "rest_framework", "rest_framework.authtoken", + "guardian", "allauth", "allauth.account", "dj_rest_auth", @@ -117,6 +118,7 @@ AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend", + "guardian.backends.ObjectPermissionBackend", ] # Internationalization @@ -230,6 +232,11 @@ REST_AUTH = {"USER_DETAILS_SERIALIZER": "apps.core.serializers.TrakUserSerializer"} +# Guardian +GUARDIAN_USER_OBJ_PERMS_MODEL = "core.UserPermission" +GUARDIAN_GROUP_OBJ_PERMS_MODEL = "core.GroupPermission" +GUARDIAN_RAISE_403 = True + TRAK_ORG_MODEL = "org.Org" TRAK_RCRAINFO_SITE_MODEL = "rcrasite.RcraSite" TRAK_MANIFEST_MODEL = "manifest.Manifest" diff --git a/server/requirements.txt b/server/requirements.txt index 8e344f08..b9767c75 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,4 +1,4 @@ -Django==5.0.6 +Django==5.0.7 django-celery-results==2.5.1 django-cors-headers==4.4.0 django-extensions==3.2.3 @@ -17,3 +17,4 @@ drf-spectacular==0.27.2 django-health-check==3.18.3 django-allauth==0.63.3 dj-rest-auth==6.0.0 +django-guardian==2.4.0