From 610cc643dd35d328e6dfc7c502b23fa7f3d2ce21 Mon Sep 17 00:00:00 2001 From: Nino Date: Thu, 30 Jul 2026 08:10:44 +0200 Subject: [PATCH 1/4] add permission for managing team and day settings, enforce on write actions --- ...teamsettings_manage_settings_permission.py | 18 +++++++ app/apps/planner/models.py | 1 + app/apps/planner/tests/tests_api.py | 53 ++++++++++++++++++- app/apps/planner/views.py | 3 ++ app/apps/users/permissions.py | 12 ++++- app/apps/users/tests/tests_api.py | 28 ++++++++++ app/apps/users/views.py | 5 ++ app/settings/urls.py | 7 ++- 8 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 app/apps/planner/migrations/0046_teamsettings_manage_settings_permission.py diff --git a/app/apps/planner/migrations/0046_teamsettings_manage_settings_permission.py b/app/apps/planner/migrations/0046_teamsettings_manage_settings_permission.py new file mode 100644 index 000000000..5eeda084c --- /dev/null +++ b/app/apps/planner/migrations/0046_teamsettings_manage_settings_permission.py @@ -0,0 +1,18 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("planner", "0045_alter_daysettings_housing_corporation_combiteam"), + ] + + operations = [ + migrations.AlterModelOptions( + name="teamsettings", + options={ + "ordering": ["name"], + "permissions": [("manage_settings", "Can manage settings")], + "verbose_name_plural": "Team settings", + }, + ), + ] diff --git a/app/apps/planner/models.py b/app/apps/planner/models.py index 86d99b8bb..4ee29dac5 100755 --- a/app/apps/planner/models.py +++ b/app/apps/planner/models.py @@ -136,6 +136,7 @@ def fetch_tags(self, auth_header=None): return response.json().get("results", []) class Meta: + permissions = [("manage_settings", "Can manage settings")] verbose_name_plural = "Team settings" ordering = ["name"] diff --git a/app/apps/planner/tests/tests_api.py b/app/apps/planner/tests/tests_api.py index 143ae19d0..c890ee3a3 100755 --- a/app/apps/planner/tests/tests_api.py +++ b/app/apps/planner/tests/tests_api.py @@ -1,4 +1,5 @@ from apps.planner.models import DaySettings, TeamSettings +from django.contrib.auth.models import Permission from django.urls import reverse from model_bakery import baker from rest_framework import status @@ -6,6 +7,7 @@ from app.utils.unittest_helpers import ( get_authenticated_client, + get_test_user, get_unauthenticated_client, ) @@ -53,6 +55,22 @@ def test_authenticated_requests_two_team_settings(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.json().get("results")), 2) + def test_authenticated_post_requires_manage_settings_permission(self): + client = get_authenticated_client() + + response = client.post(self.get_url(), {"name": "Team settings"}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_authenticated_post_with_manage_settings_permission(self): + user = get_test_user() + user.user_permissions.add(Permission.objects.get(codename="manage_settings")) + client = get_authenticated_client() + + response = client.post(self.get_url(), {"name": "Team settings"}) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + class DaySettingsViewSet(APITestCase): """ @@ -125,6 +143,17 @@ def test_authenticated_requests_update_day_settings(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json().get("name"), DAY_SETTINGS_NAME) + def test_authenticated_post_requires_manage_settings_permission(self): + team_settings = baker.make(TeamSettings) + client = get_authenticated_client() + + response = client.post( + self.get_url(), + {"name": "Day settings", "team_settings": team_settings.id}, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + class DaySettingsUpdateTestViewSet(APITestCase): """ @@ -179,5 +208,25 @@ def test_authenticated_update_empty_payload(self): client = get_authenticated_client() response = client.put(url, {}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - # self.assertEqual(response.json().get("name"), DAY_SETTINGS_NAME) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_authenticated_put_with_manage_settings_permission(self): + day_settings_id = 1 + team_settings = baker.make(TeamSettings) + baker.make( + DaySettings, + team_settings=team_settings, + id=day_settings_id, + name="FOO_NAME", + ) + + user = get_test_user() + user.user_permissions.add(Permission.objects.get(codename="manage_settings")) + + url = reverse("v1:day-settings-detail", kwargs={"pk": day_settings_id}) + client = get_authenticated_client() + response = client.put(url, {"name": "UPDATED_NAME"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json().get("name"), "UPDATED_NAME") diff --git a/app/apps/planner/views.py b/app/apps/planner/views.py index 262945849..6f02ac712 100755 --- a/app/apps/planner/views.py +++ b/app/apps/planner/views.py @@ -16,6 +16,7 @@ TeamSettingsSerializer, TeamSettingsThemeSerializer, ) +from apps.users.permissions import CanManageSettingsOrReadOnly from apps.users.utils import get_auth_header_from_request from django.conf import settings from django.contrib.auth.decorators import user_passes_test @@ -38,6 +39,7 @@ class TeamSettingsViewSet(ModelViewSet): serializer_class = TeamSettingsSerializer queryset = TeamSettings.objects.filter(enabled=True) + permission_classes = [CanManageSettingsOrReadOnly] @extend_schema( description="Gets the reasons associated with the requested team", @@ -204,6 +206,7 @@ class DaySettingsViewSet(ModelViewSet): serializer_class = DaySettingsSerializer queryset = DaySettings.objects.all() + permission_classes = [CanManageSettingsOrReadOnly] def destroy(self, request, *args, **kwargs): try: diff --git a/app/apps/users/permissions.py b/app/apps/users/permissions.py index adacc2c2d..a4406114d 100644 --- a/app/apps/users/permissions.py +++ b/app/apps/users/permissions.py @@ -1,4 +1,4 @@ -from rest_framework.permissions import BasePermission +from rest_framework.permissions import SAFE_METHODS, BasePermission class InAuthGroup(BasePermission): @@ -10,3 +10,13 @@ class IsInAuthorizedRealm(InAuthGroup): """ Keep for backwards compatibility """ + + +class CanManageSettingsOrReadOnly(InAuthGroup): + permission = "planner.manage_settings" + + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + # Read access is allowed for all authenticated users, but require the manage_settings permission for write access + return request.method in SAFE_METHODS or request.user.has_perm(self.permission) diff --git a/app/apps/users/tests/tests_api.py b/app/apps/users/tests/tests_api.py index ab433bc29..ef0658d06 100755 --- a/app/apps/users/tests/tests_api.py +++ b/app/apps/users/tests/tests_api.py @@ -1,5 +1,6 @@ from unittest.mock import Mock, patch +from django.contrib.auth.models import Group, Permission from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -107,3 +108,30 @@ def test_with_failing_authentication_code(self, mock_AuthenticationBackend): response = client.post(url, {"code": "FOO-CODE"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class CurrentUserPermissionsViewTest(APITestCase): + def test_authenticated_requests_return_all_current_user_permissions(self): + url = reverse("v1:permissions") + user = get_test_user() + group = Group.objects.create(name="planners") + permission = Permission.objects.get(codename="view_user") + group.permissions.add(permission) + user.groups.add(group) + + client = get_authenticated_client() + response = client.get(url) + + expected_permission = ( + f"{permission.content_type.app_label}.{permission.codename}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"permissions": [expected_permission]}) + + def test_unauthenticated_requests_are_rejected(self): + url = reverse("v1:permissions") + client = get_unauthenticated_client() + + response = client.get(url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/app/apps/users/views.py b/app/apps/users/views.py index 2ff02d9bd..2c9bb13e9 100755 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -27,6 +27,11 @@ def get(self, request): return Response({"is_authorized": is_authorized}) +class CurrentUserPermissionsView(APIView): + def get(self, request): + return Response({"permissions": sorted(request.user.get_all_permissions())}) + + class OIDCAuthenticateSerializer(serializers.Serializer): code = serializers.CharField(required=True) diff --git a/app/settings/urls.py b/app/settings/urls.py index 522cd35f4..82f82274d 100755 --- a/app/settings/urls.py +++ b/app/settings/urls.py @@ -7,7 +7,11 @@ from apps.planner import router as planner_router from apps.planner.views import dumpdata from apps.users import router as users_router -from apps.users.views import IsAuthorizedView, ObtainAuthTokenOIDC +from apps.users.views import ( + CurrentUserPermissionsView, + IsAuthorizedView, + ObtainAuthTokenOIDC, +) from apps.visits import router as visits_router from django.conf import settings from django.conf.urls.static import static @@ -48,6 +52,7 @@ def get(self, request, *args, **kwargs): name="oidc-authenticate", ), path("is-authorized/", IsAuthorizedView.as_view(), name="is-authorized"), + path("permissions/", CurrentUserPermissionsView.as_view(), name="permissions"), ] ) From a82e679de7df024cde861da1ec7c6b007ce1e3ac Mon Sep 17 00:00:00 2001 From: Nino Date: Thu, 30 Jul 2026 09:28:14 +0200 Subject: [PATCH 2/4] fix test --- app/apps/planner/tests/tests_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/apps/planner/tests/tests_api.py b/app/apps/planner/tests/tests_api.py index c890ee3a3..d5c8df54a 100755 --- a/app/apps/planner/tests/tests_api.py +++ b/app/apps/planner/tests/tests_api.py @@ -152,7 +152,7 @@ def test_authenticated_post_requires_manage_settings_permission(self): {"name": "Day settings", "team_settings": team_settings.id}, ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) class DaySettingsUpdateTestViewSet(APITestCase): From 0628db6a62938abba16a30c69c28cba72d4c59cd Mon Sep 17 00:00:00 2001 From: Nino Date: Tue, 4 Aug 2026 09:59:01 +0200 Subject: [PATCH 3/4] fix tests --- app/apps/planner/tests/tests_api.py | 2 +- app/apps/users/tests/tests_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/apps/planner/tests/tests_api.py b/app/apps/planner/tests/tests_api.py index d5c8df54a..7be7d7ccb 100755 --- a/app/apps/planner/tests/tests_api.py +++ b/app/apps/planner/tests/tests_api.py @@ -60,7 +60,7 @@ def test_authenticated_post_requires_manage_settings_permission(self): response = client.post(self.get_url(), {"name": "Team settings"}) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_authenticated_post_with_manage_settings_permission(self): user = get_test_user() diff --git a/app/apps/users/tests/tests_api.py b/app/apps/users/tests/tests_api.py index ef0658d06..ae25c8e9b 100755 --- a/app/apps/users/tests/tests_api.py +++ b/app/apps/users/tests/tests_api.py @@ -134,4 +134,4 @@ def test_unauthenticated_requests_are_rejected(self): response = client.get(url) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) From 5d00f72bd4cf36e38bfd09b77eca58d84469c5e7 Mon Sep 17 00:00:00 2001 From: Nino Date: Tue, 4 Aug 2026 10:19:59 +0200 Subject: [PATCH 4/4] change status code --- app/apps/planner/tests/tests_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/apps/planner/tests/tests_api.py b/app/apps/planner/tests/tests_api.py index 7be7d7ccb..c890ee3a3 100755 --- a/app/apps/planner/tests/tests_api.py +++ b/app/apps/planner/tests/tests_api.py @@ -60,7 +60,7 @@ def test_authenticated_post_requires_manage_settings_permission(self): response = client.post(self.get_url(), {"name": "Team settings"}) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_authenticated_post_with_manage_settings_permission(self): user = get_test_user() @@ -152,7 +152,7 @@ def test_authenticated_post_requires_manage_settings_permission(self): {"name": "Day settings", "team_settings": team_settings.id}, ) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) class DaySettingsUpdateTestViewSet(APITestCase):