From b6b74771731b5d08705ec94a61a6bb2b4202f34b Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Mon, 23 Sep 2024 16:20:21 +0200 Subject: [PATCH] Add user roles for organizations/gangs/sections (#1257) * Create user roles for organizations/gangs/sections * Fix user role admin names * Type annotations * Ruff format * Remove content_type from Role * Add remaining resolvers for recruitment, and org/gang/section * Add working examples for Interview and InterviewRoom views * ruff method order * mypy * Remove roles from user model * Move to samfundet app * ruff * get_perm: support permissions without period * Fix hierarchy permission checking * Add some tests * Remove RoleMixin to avoid unnecessary method calls and exception raising/catching This means when the obj hasattr check is True, we can assume the method is implemented. * Begin docs * Update migration * Add code examples to docs * Add link to docs * Fix import merge * Update migrations * Create fixture_organization2, update fixture_gang2 to use it This lets us more easily test different organizational hierarchies * Add fixture_gang_section2 * Update tests, and add hierarchy testing * Revert changes in views * ruff * Remove RoleAuthBackend fixture for more readable code * Add note about return_id to docs * [skip ci] Add real-world example to docs * Update example to a more "normal" model * Update migration --- backend/root/settings/base.py | 1 + backend/root/utils/permissions.py | 20 ++ backend/root/utils/routes.py | 24 ++ backend/samfundet/admin.py | 22 ++ backend/samfundet/backend.py | 37 +++ backend/samfundet/conftest.py | 80 +++++- ...angrole_usergangsectionrole_userorgrole.py | 75 +++++ backend/samfundet/models/__init__.py | 4 + backend/samfundet/models/general.py | 30 ++ backend/samfundet/models/recruitment.py | 57 ++++ backend/samfundet/models/role.py | 34 +++ backend/samfundet/tests/test_roles.py | 272 ++++++++++++++++++ backend/samfundet/utils.py | 11 +- docs/technical/README.md | 1 + docs/technical/backend/rolesystem.md | 85 ++++++ frontend/src/routes/backend.ts | 24 ++ 16 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 backend/samfundet/backend.py create mode 100644 backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py create mode 100644 backend/samfundet/models/role.py create mode 100644 backend/samfundet/tests/test_roles.py create mode 100644 docs/technical/backend/rolesystem.md diff --git a/backend/root/settings/base.py b/backend/root/settings/base.py index 137c2857f..789200fa7 100644 --- a/backend/root/settings/base.py +++ b/backend/root/settings/base.py @@ -129,6 +129,7 @@ AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', # default + 'samfundet.backend.RoleAuthBackend', ] # Password validation diff --git a/backend/root/utils/permissions.py b/backend/root/utils/permissions.py index 97fcad2ef..e907be694 100644 --- a/backend/root/utils/permissions.py +++ b/backend/root/utils/permissions.py @@ -278,6 +278,11 @@ SAMFUNDET_DELETE_RESERVATION = 'samfundet.delete_reservation' SAMFUNDET_VIEW_RESERVATION = 'samfundet.view_reservation' +SAMFUNDET_ADD_ROLE = 'samfundet.add_role' +SAMFUNDET_CHANGE_ROLE = 'samfundet.change_role' +SAMFUNDET_DELETE_ROLE = 'samfundet.delete_role' +SAMFUNDET_VIEW_ROLE = 'samfundet.view_role' + SAMFUNDET_ADD_SAKSDOKUMENT = 'samfundet.add_saksdokument' SAMFUNDET_CHANGE_SAKSDOKUMENT = 'samfundet.change_saksdokument' SAMFUNDET_DELETE_SAKSDOKUMENT = 'samfundet.delete_saksdokument' @@ -310,6 +315,21 @@ SAMFUNDET_DELETE_USERFEEDBACKMODEL = 'samfundet.delete_userfeedbackmodel' SAMFUNDET_VIEW_USERFEEDBACKMODEL = 'samfundet.view_userfeedbackmodel' +SAMFUNDET_ADD_USERGANGROLE = 'samfundet.add_usergangrole' +SAMFUNDET_CHANGE_USERGANGROLE = 'samfundet.change_usergangrole' +SAMFUNDET_DELETE_USERGANGROLE = 'samfundet.delete_usergangrole' +SAMFUNDET_VIEW_USERGANGROLE = 'samfundet.view_usergangrole' + +SAMFUNDET_ADD_USERGANGSECTIONROLE = 'samfundet.add_usergangsectionrole' +SAMFUNDET_CHANGE_USERGANGSECTIONROLE = 'samfundet.change_usergangsectionrole' +SAMFUNDET_DELETE_USERGANGSECTIONROLE = 'samfundet.delete_usergangsectionrole' +SAMFUNDET_VIEW_USERGANGSECTIONROLE = 'samfundet.view_usergangsectionrole' + +SAMFUNDET_ADD_USERORGROLE = 'samfundet.add_userorgrole' +SAMFUNDET_CHANGE_USERORGROLE = 'samfundet.change_userorgrole' +SAMFUNDET_DELETE_USERORGROLE = 'samfundet.delete_userorgrole' +SAMFUNDET_VIEW_USERORGROLE = 'samfundet.view_userorgrole' + SAMFUNDET_ADD_USERPREFERENCE = 'samfundet.add_userpreference' SAMFUNDET_CHANGE_USERPREFERENCE = 'samfundet.change_userpreference' SAMFUNDET_DELETE_USERPREFERENCE = 'samfundet.delete_userpreference' diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 5bd801646..8b0d4b2be 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -43,6 +43,30 @@ admin__auth_group_delete = 'admin:auth_group_delete' admin__auth_group_change = 'admin:auth_group_change' adminauthgroup__objectId = '' +admin__samfundet_role_changelist = 'admin:samfundet_role_changelist' +admin__samfundet_role_add = 'admin:samfundet_role_add' +admin__samfundet_role_history = 'admin:samfundet_role_history' +admin__samfundet_role_delete = 'admin:samfundet_role_delete' +admin__samfundet_role_change = 'admin:samfundet_role_change' +adminsamfundetrole__objectId = '' +admin__samfundet_userorgrole_changelist = 'admin:samfundet_userorgrole_changelist' +admin__samfundet_userorgrole_add = 'admin:samfundet_userorgrole_add' +admin__samfundet_userorgrole_history = 'admin:samfundet_userorgrole_history' +admin__samfundet_userorgrole_delete = 'admin:samfundet_userorgrole_delete' +admin__samfundet_userorgrole_change = 'admin:samfundet_userorgrole_change' +adminsamfundetuserorgrole__objectId = '' +admin__samfundet_usergangrole_changelist = 'admin:samfundet_usergangrole_changelist' +admin__samfundet_usergangrole_add = 'admin:samfundet_usergangrole_add' +admin__samfundet_usergangrole_history = 'admin:samfundet_usergangrole_history' +admin__samfundet_usergangrole_delete = 'admin:samfundet_usergangrole_delete' +admin__samfundet_usergangrole_change = 'admin:samfundet_usergangrole_change' +adminsamfundetusergangrole__objectId = '' +admin__samfundet_usergangsectionrole_changelist = 'admin:samfundet_usergangsectionrole_changelist' +admin__samfundet_usergangsectionrole_add = 'admin:samfundet_usergangsectionrole_add' +admin__samfundet_usergangsectionrole_history = 'admin:samfundet_usergangsectionrole_history' +admin__samfundet_usergangsectionrole_delete = 'admin:samfundet_usergangsectionrole_delete' +admin__samfundet_usergangsectionrole_change = 'admin:samfundet_usergangsectionrole_change' +adminsamfundetusergangsectionrole__objectId = '' admin__auth_permission_permissions = 'admin:auth_permission_permissions' admin__auth_permission_permissions_manage_user = 'admin:auth_permission_permissions_manage_user' admin__auth_permission_permissions_manage_group = 'admin:auth_permission_permissions_manage_group' diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index 5007a52ae..3df2c2de2 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -21,6 +21,7 @@ CustomGuardedModelAdmin, ) +from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole from .models.event import Event, EventGroup, EventRegistration, PurchaseFeedbackModel from .models.general import ( Tag, @@ -161,6 +162,27 @@ def members(self, obj: Group) -> int: return n +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + list_display = ('name',) + filter_horizontal = ['permissions'] + + +@admin.register(UserOrgRole) +class UserOrgRoleAdmin(admin.ModelAdmin): + list_display = ('user', 'role', 'obj') + + +@admin.register(UserGangRole) +class UserGangRoleAdmin(admin.ModelAdmin): + list_display = ('user', 'role', 'obj') + + +@admin.register(UserGangSectionRole) +class UserGangSectionRoleAdmin(admin.ModelAdmin): + list_display = ('user', 'role', 'obj') + + @admin.register(Permission) class PermissionAdmin(CustomGuardedModelAdmin): # ordering = [] diff --git a/backend/samfundet/backend.py b/backend/samfundet/backend.py new file mode 100644 index 000000000..7146afc59 --- /dev/null +++ b/backend/samfundet/backend.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + +from django.contrib.auth.backends import BaseBackend + +from samfundet.utils import get_perm +from samfundet.models import User +from samfundet.models.role import UserOrgRole, UserGangRole, UserGangSectionRole + + +class RoleAuthBackend(BaseBackend): + def has_perm(self, user_obj: User, perm: str, obj: Any = None) -> bool: # noqa: C901 + if not user_obj.is_active or obj is None: + return False + + if user_obj.is_superuser: + return True + + permission = get_perm(perm=perm, model=obj) + + if hasattr(obj, 'resolve_org'): + org_id = obj.resolve_org(return_id=True) + if org_id is not None and UserOrgRole.objects.filter(user=user_obj, obj__id=org_id, role__permissions=permission).exists(): + return True + + if hasattr(obj, 'resolve_gang'): + gang_id = obj.resolve_gang(return_id=True) + if gang_id is not None and UserGangRole.objects.filter(user=user_obj, obj__id=gang_id, role__permissions=permission).exists(): + return True + + if hasattr(obj, 'resolve_section'): + section_id = obj.resolve_section(return_id=True) + if section_id is not None and UserGangSectionRole.objects.filter(user=user_obj, obj__id=section_id, role__permissions=permission).exists(): + return True + + return False diff --git a/backend/samfundet/conftest.py b/backend/samfundet/conftest.py index 206cfed73..8efc8ff6e 100644 --- a/backend/samfundet/conftest.py +++ b/backend/samfundet/conftest.py @@ -11,12 +11,14 @@ from django.test import Client, TestCase from django.utils import timezone from django.core.files.images import ImageFile -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType import root.management.commands.seed_scripts.billig as billig_seed from root.settings import BASE_DIR from samfundet.constants import DEV_PASSWORD +from samfundet.models.role import Role from samfundet.models.event import Event from samfundet.models.billig import BilligEvent from samfundet.models.general import ( @@ -29,6 +31,7 @@ Campus, BlogPost, TextItem, + GangSection, Reservation, Organization, MerchVariation, @@ -225,6 +228,13 @@ def fixture_organization() -> Iterator[Organization]: organization.delete() +@pytest.fixture +def fixture_organization2() -> Iterator[Organization]: + organization = Organization.objects.create(name='UKA') + yield organization + organization.delete() + + @pytest.fixture def fixture_gang(fixture_organization: Organization) -> Iterator[Gang]: organization = Gang.objects.create( @@ -238,17 +248,81 @@ def fixture_gang(fixture_organization: Organization) -> Iterator[Gang]: @pytest.fixture -def fixture_gang2(fixture_organization: Organization) -> Iterator[Gang]: +def fixture_gang2(fixture_organization2: Organization) -> Iterator[Gang]: organization = Gang.objects.create( name_nb='Gang 2', name_en='Gang 2', abbreviation='G2', - organization=fixture_organization, + organization=fixture_organization2, ) yield organization organization.delete() +@pytest.fixture +def fixture_gang_section(fixture_gang: Gang) -> Iterator[GangSection]: + gang_section = GangSection.objects.create( + name_nb='Test Gang Section', + name_en='Test Gang Section', + gang=fixture_gang, + ) + yield gang_section + gang_section.delete() + + +@pytest.fixture +def fixture_gang_section2(fixture_gang2: Gang) -> Iterator[GangSection]: + gang_section = GangSection.objects.create( + name_nb='Test Gang Section 2', + name_en='Test Gang Section 2', + gang=fixture_gang2, + ) + yield gang_section + gang_section.delete() + + +@pytest.fixture +def fixture_role() -> Iterator[Role]: + role = Role.objects.create( + name='Test Role', + ) + yield role + role.delete() + + +@pytest.fixture +def fixture_org_permission() -> Iterator[Permission]: + permission = Permission.objects.create( + name='Test Org Permission', + codename='test_org_permission', + content_type=ContentType.objects.get_for_model(Organization), + ) + yield permission + permission.delete() + + +@pytest.fixture +def fixture_gang_permission() -> Iterator[Permission]: + permission = Permission.objects.create( + name='Test Gang Permission', + codename='test_gang_permission', + content_type=ContentType.objects.get_for_model(Gang), + ) + yield permission + permission.delete() + + +@pytest.fixture +def fixture_gang_section_permission() -> Iterator[Permission]: + permission = Permission.objects.create( + name='Test Gang Section Permission', + codename='test_gang_section_permission', + content_type=ContentType.objects.get_for_model(GangSection), + ) + yield permission + permission.delete() + + @pytest.fixture def fixture_text_item() -> Iterator[TextItem]: text_item = TextItem.objects.create( diff --git a/backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py b/backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py new file mode 100644 index 000000000..8f72f9cd5 --- /dev/null +++ b/backend/samfundet/migrations/0002_role_usergangrole_usergangsectionrole_userorgrole.py @@ -0,0 +1,75 @@ +# Generated by Django 5.0.7 on 2024-09-17 18:27 + +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'), + ('samfundet', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('permissions', models.ManyToManyField(to='auth.permission')), + ], + ), + migrations.CreateModel( + name='UserGangRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('obj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.gang')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.role')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserGangSectionRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('obj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.gangsection')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.role')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserOrgRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('obj', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.organization')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='samfundet.role')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/samfundet/models/__init__.py b/backend/samfundet/models/__init__.py index 9b8a85b70..8e427388b 100644 --- a/backend/samfundet/models/__init__.py +++ b/backend/samfundet/models/__init__.py @@ -12,12 +12,16 @@ User, Image, Profile, + GangSection, + Organization, UserPreference, ) __all__ = [ 'User', 'Gang', + 'GangSection', + 'Organization', 'Event', 'Image', 'Profile', diff --git a/backend/samfundet/models/general.py b/backend/samfundet/models/general.py index 51c2741da..92a29a6e0 100644 --- a/backend/samfundet/models/general.py +++ b/backend/samfundet/models/general.py @@ -273,6 +273,11 @@ class Meta: verbose_name = 'Organization' verbose_name_plural = 'Organizations' + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + if return_id: + return self.id + return self + def __str__(self) -> str: return self.name @@ -323,6 +328,17 @@ class Meta: verbose_name = 'Gang' verbose_name_plural = 'Gangs' + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + if return_id: + # noinspection PyTypeChecker + return self.organization_id + return self.organization + + def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + if return_id: + return self.id + return self + def __str__(self) -> str: return f'{self.gang_type} - {self.name_nb}' @@ -333,6 +349,20 @@ class GangSection(CustomBaseModel): logo = models.ForeignKey(Image, on_delete=models.PROTECT, blank=True, null=True, verbose_name='Logo') gang = models.ForeignKey(Gang, blank=False, null=False, related_name='gang', on_delete=models.PROTECT, verbose_name='Gjeng') + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.gang.resolve_org(return_id=return_id) + + def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + if return_id: + # noinspection PyTypeChecker + return self.gang_id + return self.gang + + def resolve_section(self, *, return_id: bool = False) -> GangSection | int: + if return_id: + return self.id + return self + def __str__(self) -> str: return f'{self.gang.name_nb} - {self.name_nb}' diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 52ccc137c..b7d351329 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -32,6 +32,12 @@ class Recruitment(CustomBaseModel): max_applications = models.PositiveIntegerField(null=True, blank=True, verbose_name='Max applications per applicant') + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + if return_id: + # noinspection PyTypeChecker + return self.organization_id + return self.organization + def recruitment_progress(self) -> float: applications = RecruitmentApplication.objects.filter(recruitment=self) if applications.count() == 0: @@ -155,6 +161,15 @@ class RecruitmentPosition(CustomBaseModel): # TODO: Implement interviewer functionality interviewers = models.ManyToManyField(to=User, help_text='Interviewers for the position', blank=True, related_name='interviewers') + def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + if return_id: + # noinspection PyTypeChecker + return self.gang_id + return self.gang + + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.gang.resolve_org(return_id=return_id) + def __str__(self) -> str: return f'Position: {self.name_en} in {self.recruitment}' @@ -184,6 +199,9 @@ class RecruitmentSeparatePosition(CustomBaseModel): blank=True, ) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment.resolve_org(return_id=return_id) + def __str__(self) -> str: return f'Seperate recruitment: {self.name_nb} ({self.recruitment})' @@ -199,6 +217,15 @@ class InterviewRoom(CustomBaseModel): def __str__(self) -> str: return self.name + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment.resolve_org(return_id=return_id) + + def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + if return_id: + # noinspection PyTypeChecker + return self.gang_id + return self.gang + def clean(self) -> None: super().clean() @@ -223,6 +250,12 @@ class Interview(CustomBaseModel): interviewers = models.ManyToManyField(to='samfundet.User', help_text='Interviewers for this interview', blank=True, related_name='interviews') notes = models.TextField(help_text='Notes for the interview', null=True, blank=True) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.room.resolve_org(return_id=return_id) + + def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + return self.room.resolve_gang(return_id=return_id) + class RecruitmentApplication(CustomBaseModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -253,6 +286,12 @@ class RecruitmentApplication(CustomBaseModel): choices=RecruitmentApplicantStates.choices, default=RecruitmentApplicantStates.NOT_SET, help_text='The state of the applicant for the recruiter' ) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment.resolve_org(return_id=return_id) + + def resolve_gang(self, *, return_id: bool = False) -> Gang | int: + return self.recruitment_position.resolve_gang(return_id=return_id) + def organize_priorities(self) -> None: """Organizes priorites from 1 to n, so that it is sequential with no gaps""" applications_for_user = RecruitmentApplication.objects.filter(recruitment=self.recruitment, user=self.user).order_by('applicant_priority') @@ -383,6 +422,9 @@ class RecruitmentInterviewAvailability(CustomBaseModel): end_time = models.TimeField(help_text='Last possible time of day for interviews', default='23:00:00', null=False, blank=False) timeslot_interval = models.PositiveSmallIntegerField(help_text='The time interval (in minutes) between each timeslot', default=30) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment.resolve_org(return_id=return_id) + class OccupiedTimeslot(FullCleanSaveMixin): user = models.ForeignKey( @@ -403,6 +445,9 @@ class OccupiedTimeslot(FullCleanSaveMixin): class Meta: constraints = [models.UniqueConstraint(fields=['user', 'recruitment', 'start_dt', 'end_dt'], name='occupied_UNIQ')] + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment.resolve_org(return_id=return_id) + class RecruitmentStatistics(FullCleanSaveMixin): recruitment = models.OneToOneField(Recruitment, on_delete=models.CASCADE, blank=True, null=True, related_name='statistics') @@ -422,6 +467,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None: def __str__(self) -> str: return f'{self.recruitment} stats' + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment.resolve_org(return_id=return_id) + def generate_time_stats(self) -> None: for h in range(0, 24): time_stat, created = RecruitmentTimeStat.objects.get_or_create(recruitment_stats=self, hour=h) @@ -465,6 +513,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None: self.count = count super().save(*args, **kwargs) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment_stats.resolve_org(return_id=return_id) + class RecruitmentDateStat(models.Model): recruitment_stats = models.ForeignKey(RecruitmentStatistics, on_delete=models.CASCADE, blank=False, null=False, related_name='date_stats') @@ -482,6 +533,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None: self.count = count super().save(*args, **kwargs) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment_stats.resolve_org(return_id=return_id) + class RecruitmentCampusStat(models.Model): recruitment_stats = models.ForeignKey(RecruitmentStatistics, on_delete=models.CASCADE, blank=False, null=False, related_name='campus_stats') @@ -498,6 +552,9 @@ def save(self, *args: tuple, **kwargs: dict) -> None: ).count() super().save(*args, **kwargs) + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.recruitment_stats.resolve_org(return_id=return_id) + class RecruitmentGangStat(models.Model): recruitment_stats = models.ForeignKey(RecruitmentStatistics, on_delete=models.CASCADE, blank=False, null=False, related_name='gang_stats') diff --git a/backend/samfundet/models/role.py b/backend/samfundet/models/role.py new file mode 100644 index 000000000..826ffab14 --- /dev/null +++ b/backend/samfundet/models/role.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from django.db import models +from django.conf import settings + +from root.utils.mixins import CustomBaseModel + + +class Role(models.Model): + name = models.CharField(max_length=255) + permissions = models.ManyToManyField('auth.Permission') + + def __str__(self) -> str: + return self.name + + +class UserRoleBase(CustomBaseModel): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + role = models.ForeignKey(Role, on_delete=models.CASCADE) + + class Meta: + abstract = True + + +class UserOrgRole(UserRoleBase): + obj = models.ForeignKey('samfundet.Organization', on_delete=models.CASCADE) + + +class UserGangRole(UserRoleBase): + obj = models.ForeignKey('samfundet.Gang', on_delete=models.CASCADE) + + +class UserGangSectionRole(UserRoleBase): + obj = models.ForeignKey('samfundet.GangSection', on_delete=models.CASCADE) diff --git a/backend/samfundet/tests/test_roles.py b/backend/samfundet/tests/test_roles.py new file mode 100644 index 000000000..859ecee98 --- /dev/null +++ b/backend/samfundet/tests/test_roles.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from django.contrib.auth.models import Permission + +from samfundet.models import Gang, User, GangSection, Organization +from samfundet.backend import RoleAuthBackend +from samfundet.models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole + + +def test_has_perm_superuser(fixture_superuser: User, fixture_organization: Organization, fixture_org_permission: Permission): + """Test that superusers have permissions to all resources even without any roles.""" + backend = RoleAuthBackend() + assert backend.has_perm(fixture_superuser, fixture_org_permission.codename, fixture_organization) + + +def test_has_perm_inactive_user( + fixture_user: User, + fixture_organization: Organization, + fixture_org_permission: Permission, +): + backend = RoleAuthBackend() + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + + +def test_has_perm_inactive_user_with_role(fixture_user: User, fixture_organization: Organization, fixture_org_permission: Permission, fixture_role: Role): + """Test that inactive users who would otherwise have access to a resource, don't.""" + backend = RoleAuthBackend() + fixture_role.permissions.add(fixture_org_permission) + UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization) + fixture_user.is_active = False + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + + +def test_has_perm_no_obj(fixture_user: User, fixture_org_permission: Permission): + backend = RoleAuthBackend() + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, None) + + +def test_has_perm_superuser_no_obj(fixture_superuser: User, fixture_org_permission: Permission): + backend = RoleAuthBackend() + assert not backend.has_perm(fixture_superuser, fixture_org_permission.codename, None) + + +def test_has_perm_user_with_no_roles( + fixture_user: User, + fixture_role: Role, + fixture_organization: Organization, + fixture_gang: Gang, + fixture_gang_section: GangSection, + fixture_org_permission: Permission, + fixture_gang_permission: Permission, + fixture_gang_section_permission: Permission, +): + backend = RoleAuthBackend() + """Sanity check. Within the scope of our auth backend, a user with no roles should have no permissions, + on any hierarchical level.""" + + # Create a role with permission to our example resources (org/gang/section), but don't attach it to user. + fixture_role.permissions.add(fixture_org_permission) + fixture_role.permissions.add(fixture_gang_permission) + fixture_role.permissions.add(fixture_gang_section_permission) + + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + +def test_has_perm_user_with_org_role(fixture_user: User, fixture_role: Role, fixture_organization: Organization, fixture_org_permission: Permission): + backend = RoleAuthBackend() + """Test that giving user an OrgRole with permission to access a resource, actually gives them access.""" + fixture_role.permissions.add(fixture_org_permission) + UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization) + + assert backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + + +def test_has_perm_user_with_gang_role(fixture_user: User, fixture_role: Role, fixture_gang: Gang, fixture_gang_permission: Permission): + backend = RoleAuthBackend() + """Test that giving user a GangRole with permission to access a resource, actually gives them access.""" + fixture_role.permissions.add(fixture_gang_permission) + UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang) + + assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + + +def test_has_perm_user_with_section_role( + fixture_user: User, + fixture_role: Role, + fixture_gang_section: GangSection, + fixture_gang_section_permission: Permission, +): + backend = RoleAuthBackend() + """Test that giving user a GangSectionRole with permission to access a resource, actually gives them access.""" + fixture_role.permissions.add(fixture_gang_section_permission) + UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section) + + assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + +def test_has_perm_different_orgs( + fixture_user: User, + fixture_organization: Organization, + fixture_organization2: Organization, + fixture_org_permission: Permission, + fixture_role: Role, +): + backend = RoleAuthBackend() + """Test that giving user a role to a specific org, does not give it to other orgs""" + fixture_role.permissions.add(fixture_org_permission) + + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + + UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization) + + assert backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization2) + + +def test_has_perm_different_gangs( + fixture_user: User, + fixture_gang: Gang, + fixture_gang2: Gang, + fixture_gang_permission: Permission, + fixture_role: Role, +): + backend = RoleAuthBackend() + """Test that giving user a role to a specific gang, does not give it to other gangs""" + fixture_role.permissions.add(fixture_gang_permission) + + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + + UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang) + + assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang2) + + +def test_has_perm_different_gang_sections( + fixture_user: User, + fixture_gang_section: GangSection, + fixture_gang_section2: Gang, + fixture_gang_section_permission: Permission, + fixture_role: Role, +): + backend = RoleAuthBackend() + """Test that giving user a role to a specific gang section, does not give it to other gang sections""" + fixture_role.permissions.add(fixture_gang_section_permission) + + assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section) + + assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section2) + + +def test_has_perm_different_users( + fixture_user: User, + fixture_user2: User, + fixture_organization: Organization, + fixture_gang: Gang, + fixture_gang_section: GangSection, + fixture_org_permission: Permission, + fixture_gang_permission: Permission, + fixture_gang_section_permission: Permission, + fixture_role: Role, +): + backend = RoleAuthBackend() + """Test that giving user a role, does not give it to other users""" + fixture_role.permissions.add(fixture_org_permission) + fixture_role.permissions.add(fixture_gang_permission) + fixture_role.permissions.add(fixture_gang_section_permission) + + UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization) + UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang) + UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section) + + assert not backend.has_perm(fixture_user2, fixture_org_permission.codename, fixture_organization) + assert not backend.has_perm(fixture_user2, fixture_gang_permission.codename, fixture_gang) + assert not backend.has_perm(fixture_user2, fixture_gang_section_permission.codename, fixture_gang_section) + + +def test_has_perm_org_downward( + fixture_user: User, + fixture_organization: Organization, + fixture_organization2: Organization, + fixture_gang: Gang, + fixture_gang2: Gang, + fixture_gang_section: GangSection, + fixture_role: Role, + fixture_org_permission: Permission, + fixture_gang_permission: Permission, + fixture_gang_section_permission: Permission, +): + backend = RoleAuthBackend() + """Test that giving permission on org/gang level, also gives it downwards (gang/section).""" + fixture_role.permissions.add(fixture_gang_section_permission) + fixture_role.permissions.add(fixture_gang_permission) + + fixture_gang.organization = fixture_organization + fixture_gang_section.gang = fixture_gang + + # Giving a user an Org role should give the same permissions on Gang and Section levels + + org_role = UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization) + + assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + org_role.delete() + + # Permissions should be gone after deleting org role + + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + # Giving a user a Gang role should give the same permissions on Section level + + gang_role = UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang) + + assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + gang_role.delete() + + assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + # Give the user the Org role again, and ensure that after we detach Gang and Section from the Organization, + # we no longer have permissions. + UserOrgRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_organization) + + assert backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + fixture_gang_section.gang = fixture_gang2 + + assert not backend.has_perm(fixture_user, fixture_gang_section_permission.codename, fixture_gang_section) + + assert backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + + fixture_gang.organization = fixture_organization2 + + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + + +def test_has_perm_section_upward( + fixture_user: User, + fixture_organization: Organization, + fixture_gang: Gang, + fixture_gang_section: GangSection, + fixture_role: Role, + fixture_org_permission: Permission, + fixture_gang_permission: Permission, + fixture_gang_section_permission: Permission, +): + backend = RoleAuthBackend() + """Test that giving permission on section/gang level, does not give it upwards (gang/org).""" + fixture_role.permissions.add(fixture_org_permission) + fixture_role.permissions.add(fixture_gang_permission) + fixture_role.permissions.add(fixture_gang_section_permission) + + fixture_gang.organization = fixture_organization + fixture_gang_section.gang = fixture_gang + + section_role = UserGangSectionRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang_section) + + assert not backend.has_perm(fixture_user, fixture_gang_permission.codename, fixture_gang) + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) + + section_role.delete() + + UserGangRole.objects.create(user=fixture_user, role=fixture_role, obj=fixture_gang) + + assert not backend.has_perm(fixture_user, fixture_org_permission.codename, fixture_organization) diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 0d4fda08b..6755ad78d 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -3,10 +3,12 @@ import datetime from django.http import QueryDict -from django.db.models import Q +from django.db.models import Q, Model from django.utils.timezone import make_aware from django.core.exceptions import ValidationError from django.db.models.query import QuerySet +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType from .models import User from .models.event import Event @@ -90,3 +92,10 @@ def get_occupied_timeslots_from_request( occupied_timeslots.append(OccupiedTimeslot(user=user, recruitment=recruitment, start_dt=start_date, end_dt=end_date)) return occupied_timeslots + + +def get_perm(*, perm: str, model: type[Model]) -> Permission: + codename = perm.split('.')[1] if '.' in perm else perm + content_type = ContentType.objects.get_for_model(model=model) + permission = Permission.objects.get(codename=codename, content_type=content_type) + return permission diff --git a/docs/technical/README.md b/docs/technical/README.md index 1d5752bbf..feb7553cd 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -14,6 +14,7 @@ - [Billig (payment system)](/docs/technical/backend/billig.md) - [Seed scripts](/docs/technical/backend/seed.md) +- [Role System](/docs/technical/backend/rolesystem.md) ### Pipelines & Deployment - [Pipeline (mypy, eslint, tsc, ...)](/docs/technical/pipeline.md) diff --git a/docs/technical/backend/rolesystem.md b/docs/technical/backend/rolesystem.md new file mode 100644 index 000000000..833e73633 --- /dev/null +++ b/docs/technical/backend/rolesystem.md @@ -0,0 +1,85 @@ +# Role System + +The role system in Samfundet4 builds on the Django "authentication backend" concept. Our system adds +a [custom auth backend](https://docs.djangoproject.com/en/5.0/topics/auth/customizing/). The goal of the system is to +enable us to answer queries like: + +> Does Bob have access to edit case document X, which belongs to the UKA organization? +> +>Does Bob have access to view recruitment position X, which belongs to Web (a section of MG in the Samfundet +> organization) + +The system uses hierarchical permission checking. It first checks if a user has a permission to a specific object on the +Organizational level, then Gang level, and finally the Gang Section level. This means that if a user has a specific +permission for an object on the Organizational level, they also have it on the Gang and Gang Section levels. And if a +user has it on the Gang level, they also have it on the Gang Section level. + +## Real-world example + +Before we get into the technical details of the system, it's important to know how the system is used, so here's a +real-world example. + +Say we have a "Interviewer" role. This role gives permissions to view and manage interviews in a recruitment. If the +user is given this role on the Organization level, it means they are able to manage absolutely all interviews for gangs +and sections which belong to the organization. If they are given the role on the Gang level, they are able to manage all +interviews for the gang and the gang's sections. And finally, if they are given the role on the Gang Section level, they +are only able to manage interviews belonging to the gang section. + +## Organization/Gang/Section resolvers + +For the auth backend to know what organization an object belongs to, models need to implement +the `resolve_org`/`resolve_gang`/`resolve_section` methods. The purpose of these resolvers is to return the +org/gang/section the object belongs to. Models implementing these methods may not have ambiguous ownership, or be owned +by multiple orgs/gangs/sections. + +> Example: The Venue model doesn't implement these methods, as they aren't "owned" by anybody +> +> Example: A Recruitment is owned by an organization, therefore `resolve_org` returns that organization. + +These resolver methods only have a single parameter: `return_id`. The purpose of this argument is to avoid having to +unnecessarily fetch a whole instance from the database, when we only need the ID. All models which implement the +resolvers must respect this argument if possible. + +Here's an example implementation of `resolve_org` for the Recruitment model: + +```python +class Recruitment(CustomBaseModel): + ... + organization = models.ForeignKey(to=Organization) + + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + if return_id: + return self.organization_id + return self.organization +``` + +And another example, showing how models can just call each other's resolvers to greatly simplify things: + +```python +class RecruitmentPosition(CustomBaseModel): + ... + gang = models.ForeignKey(to=Gang) + + def resolve_org(self, *, return_id: bool = False) -> Organization | int: + return self.gang.resolve_org(return_id=return_id) +``` + +## Role + +A Role simply contains a name and a list of permissions. An "Interviewer" Role may for example contain permissions +related to interviews, interview rooms, applications, etc. + +## Organization/Gang/Section User Roles + +To tie users together to roles, we use either `UserOrgRole`/`UserGangRole`/`UserGangSectionRole`. These models contain +three fields: user, role, and object. For `UserOrgRole`, the obj will be an instance of Organization, for `UserGangRole` +it'll be an instance of Gang, and for `UserGangSectionRole` it'll be an instance of a GangSection. + +To reiterate: if we create a `UserOrgRole` instance, it gives the user all of the role's permissions for the given +organization. + +## Inheritance + +There is no inheritance in our system, as that often leads to unnecessary complexity, both in the code and in our mental +understanding of how a specific role – or a set of roles – operates. If something is wrong with a role's permissions, +you can simply fix it then and there, instead of looking up and down the inheritance tree to see where the issue is. diff --git a/frontend/src/routes/backend.ts b/frontend/src/routes/backend.ts index 1ad8ec5b5..b1b8171d9 100644 --- a/frontend/src/routes/backend.ts +++ b/frontend/src/routes/backend.ts @@ -42,6 +42,30 @@ export const ROUTES_BACKEND = { admin__auth_group_delete: '/admin/auth/group/:objectId/delete/', admin__auth_group_change: '/admin/auth/group/:objectId/change/', adminauthgroup__objectId: '/admin/auth/group/:objectId/', + admin__samfundet_role_changelist: '/admin/samfundet/role/', + admin__samfundet_role_add: '/admin/samfundet/role/add/', + admin__samfundet_role_history: '/admin/samfundet/role/:objectId/history/', + admin__samfundet_role_delete: '/admin/samfundet/role/:objectId/delete/', + admin__samfundet_role_change: '/admin/samfundet/role/:objectId/change/', + adminsamfundetrole__objectId: '/admin/samfundet/role/:objectId/', + admin__samfundet_userorgrole_changelist: '/admin/samfundet/userorgrole/', + admin__samfundet_userorgrole_add: '/admin/samfundet/userorgrole/add/', + admin__samfundet_userorgrole_history: '/admin/samfundet/userorgrole/:objectId/history/', + admin__samfundet_userorgrole_delete: '/admin/samfundet/userorgrole/:objectId/delete/', + admin__samfundet_userorgrole_change: '/admin/samfundet/userorgrole/:objectId/change/', + adminsamfundetuserorgrole__objectId: '/admin/samfundet/userorgrole/:objectId/', + admin__samfundet_usergangrole_changelist: '/admin/samfundet/usergangrole/', + admin__samfundet_usergangrole_add: '/admin/samfundet/usergangrole/add/', + admin__samfundet_usergangrole_history: '/admin/samfundet/usergangrole/:objectId/history/', + admin__samfundet_usergangrole_delete: '/admin/samfundet/usergangrole/:objectId/delete/', + admin__samfundet_usergangrole_change: '/admin/samfundet/usergangrole/:objectId/change/', + adminsamfundetusergangrole__objectId: '/admin/samfundet/usergangrole/:objectId/', + admin__samfundet_usergangsectionrole_changelist: '/admin/samfundet/usergangsectionrole/', + admin__samfundet_usergangsectionrole_add: '/admin/samfundet/usergangsectionrole/add/', + admin__samfundet_usergangsectionrole_history: '/admin/samfundet/usergangsectionrole/:objectId/history/', + admin__samfundet_usergangsectionrole_delete: '/admin/samfundet/usergangsectionrole/:objectId/delete/', + admin__samfundet_usergangsectionrole_change: '/admin/samfundet/usergangsectionrole/:objectId/change/', + adminsamfundetusergangsectionrole__objectId: '/admin/samfundet/usergangsectionrole/:objectId/', admin__auth_permission_permissions: '/admin/auth/permission/:objectPk/permissions/', admin__auth_permission_permissions_manage_user: '/admin/auth/permission/:objectPk/permissions/user-manage/:userId/', admin__auth_permission_permissions_manage_group: '/admin/auth/permission/:objectPk/permissions/group-manage/:groupId/',