Skip to content

Commit

Permalink
Add user roles for organizations/gangs/sections (#1257)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
robines committed Sep 23, 2024
1 parent df4e06f commit b6b7477
Show file tree
Hide file tree
Showing 16 changed files with 773 additions and 4 deletions.
1 change: 1 addition & 0 deletions backend/root/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@

AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', # default
'samfundet.backend.RoleAuthBackend',
]

# Password validation
Expand Down
20 changes: 20 additions & 0 deletions backend/root/utils/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
24 changes: 24 additions & 0 deletions backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions backend/samfundet/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = []
Expand Down
37 changes: 37 additions & 0 deletions backend/samfundet/backend.py
Original file line number Diff line number Diff line change
@@ -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
80 changes: 77 additions & 3 deletions backend/samfundet/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -29,6 +31,7 @@
Campus,
BlogPost,
TextItem,
GangSection,
Reservation,
Organization,
MerchVariation,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
4 changes: 4 additions & 0 deletions backend/samfundet/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@
User,
Image,
Profile,
GangSection,
Organization,
UserPreference,
)

__all__ = [
'User',
'Gang',
'GangSection',
'Organization',
'Event',
'Image',
'Profile',
Expand Down
Loading

0 comments on commit b6b7477

Please sign in to comment.